mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 尝试增加一下聊天里面的语音转文字功能
This commit is contained in:
@@ -15,6 +15,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
|||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -442,6 +443,10 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getVoiceData(sessionId, msgId)
|
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) => {
|
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
||||||
return chatService.getMessageById(sessionId, localId)
|
return chatService.getMessageById(sessionId, localId)
|
||||||
})
|
})
|
||||||
@@ -516,6 +521,16 @@ function registerIpcHandlers() {
|
|||||||
return { success: true }
|
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 () => {
|
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||||
return groupAnalyticsService.getGroupChats()
|
return groupAnalyticsService.getGroupChats()
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
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),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
||||||
|
},
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { MessageCacheService } from './messageCacheService'
|
import { MessageCacheService } from './messageCacheService'
|
||||||
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||||
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
|
|
||||||
type HardlinkState = {
|
type HardlinkState = {
|
||||||
db: Database.Database
|
db: Database.Database
|
||||||
@@ -83,6 +84,10 @@ class ChatService {
|
|||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
private hardlinkCache = new Map<string, HardlinkState>()
|
||||||
private readonly contactCacheService: ContactCacheService
|
private readonly contactCacheService: ContactCacheService
|
||||||
private readonly messageCacheService: MessageCacheService
|
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 readonly voiceCacheMaxEntries = 50
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -1738,6 +1743,9 @@ class ChatService {
|
|||||||
|
|
||||||
if (includeMessages) {
|
if (includeMessages) {
|
||||||
this.messageCacheService.clear()
|
this.messageCacheService.clear()
|
||||||
|
this.voiceWavCache.clear()
|
||||||
|
this.voiceTranscriptCache.clear()
|
||||||
|
this.voiceTranscriptPending.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const state of this.hardlinkCache.values()) {
|
for (const state of this.hardlinkCache.values()) {
|
||||||
@@ -2263,6 +2271,8 @@ class ChatService {
|
|||||||
const pcmData = readFileSync(pcmFile)
|
const pcmData = readFileSync(pcmFile)
|
||||||
const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz
|
const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz
|
||||||
const wavData = Buffer.concat([wavHeader, pcmData])
|
const wavData = Buffer.concat([wavHeader, pcmData])
|
||||||
|
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||||
|
this.cacheVoiceWav(cacheKey, wavData)
|
||||||
|
|
||||||
return { success: true, data: wavData.toString('base64') }
|
return { success: true, data: wavData.toString('base64') }
|
||||||
} finally {
|
} 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 {
|
private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer {
|
||||||
const header = Buffer.alloc(44)
|
const header = Buffer.alloc(44)
|
||||||
header.write('RIFF', 0)
|
header.write('RIFF', 0)
|
||||||
@@ -2294,6 +2343,26 @@ class ChatService {
|
|||||||
return header
|
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 }> {
|
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||||
try {
|
try {
|
||||||
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
|
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface ConfigSchema {
|
|||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
|
whisperModelName: string
|
||||||
|
whisperModelDir: string
|
||||||
|
whisperDownloadSource: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -42,7 +45,10 @@ export class ConfigService {
|
|||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false,
|
logEnabled: false,
|
||||||
llmModelPath: ''
|
llmModelPath: '',
|
||||||
|
whisperModelName: 'base',
|
||||||
|
whisperModelDir: '',
|
||||||
|
whisperDownloadSource: 'tsinghua'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
281
electron/services/voiceTranscribeService.ts
Normal file
281
electron/services/voiceTranscribeService.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
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<string, WhisperModelInfo> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||||
|
|
||||||
|
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 } = await execFileAsync(whisperExe, [
|
||||||
|
'-m', modelPath,
|
||||||
|
'-f', outputPath,
|
||||||
|
'-l', 'zh',
|
||||||
|
'-otxt'
|
||||||
|
], {
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
cwd: tempDir
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析输出文本
|
||||||
|
const txtFile = outputPath.replace(/\.[^.]+$/, '.txt')
|
||||||
|
let transcript = ''
|
||||||
|
if (existsSync(txtFile)) {
|
||||||
|
const { readFileSync } = await import('fs')
|
||||||
|
transcript = readFileSync(txtFile, 'utf-8').trim()
|
||||||
|
unlinkSync(txtFile)
|
||||||
|
} else {
|
||||||
|
// 从 stdout 提取
|
||||||
|
const lines = stdout.split('\n').filter(line => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
return trimmed && !trimmed.startsWith('[') && !trimmed.startsWith('whisper_')
|
||||||
|
})
|
||||||
|
transcript = lines.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('[VoiceTranscribe] success', { transcript })
|
||||||
|
return { success: true, transcript }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VoiceTranscribe] failed', error)
|
||||||
|
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<void> {
|
||||||
|
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()
|
||||||
22
electron/types/whisper-node.d.ts
vendored
Normal file
22
electron/types/whisper-node.d.ts
vendored
Normal 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[]>
|
||||||
|
}
|
||||||
123
package-lock.json
generated
123
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
|
"whisper-node": "^1.1.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3746,7 +3747,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
"node_modules/base64-arraybuffer": {
|
||||||
@@ -3852,7 +3852,6 @@
|
|||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -4451,7 +4450,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
@@ -5792,7 +5790,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -5814,7 +5811,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -5933,7 +5929,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
||||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fs.realpath": "^1.0.0",
|
"fs.realpath": "^1.0.0",
|
||||||
@@ -5954,7 +5949,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
@@ -6109,7 +6103,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -6334,7 +6327,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
|
||||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
"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.",
|
"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",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.3.0",
|
"once": "^1.3.0",
|
||||||
@@ -6353,6 +6345,15 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
@@ -6376,6 +6377,21 @@
|
|||||||
"is-ci": "bin.js"
|
"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": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -7506,7 +7522,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -7522,6 +7537,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-scurry": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
@@ -7911,6 +7932,26 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -7948,6 +7989,26 @@
|
|||||||
"url": "https://github.com/sponsors/jet2jet"
|
"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": {
|
"node_modules/resolve-alpn": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||||
@@ -8281,6 +8342,23 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -8588,6 +8666,18 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tar": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz",
|
||||||
@@ -9219,6 +9309,19 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmmirror.com/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
|
"whisper-node": "^1.1.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -103,4 +104,4 @@
|
|||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
resources/whisper-cli.exe
Normal file
BIN
resources/whisper-cli.exe
Normal file
Binary file not shown.
BIN
resources/whisper-main.exe
Normal file
BIN
resources/whisper-main.exe
Normal file
Binary file not shown.
@@ -1303,6 +1303,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-bubble.sent .voice-message {
|
.message-bubble.sent .voice-message {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1397,34 @@
|
|||||||
color: #d9480f;
|
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 {
|
@keyframes voicePulse {
|
||||||
0% {
|
0% {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -1847,4 +1881,4 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1280,6 +1280,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const emojiDataUrlCache = new Map<string, string>()
|
const emojiDataUrlCache = new Map<string, string>()
|
||||||
const imageDataUrlCache = new Map<string, string>()
|
const imageDataUrlCache = new Map<string, string>()
|
||||||
const voiceDataUrlCache = 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 senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
@@ -1312,6 +1313,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||||
const voiceAudioRef = useRef<HTMLAudioElement | null>(null)
|
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 [showImagePreview, setShowImagePreview] = useState(false)
|
||||||
|
|
||||||
// 从缓存获取表情包 data URL
|
// 从缓存获取表情包 data URL
|
||||||
@@ -1327,6 +1331,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
|
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
|
||||||
() => voiceDataUrlCache.get(voiceCacheKey)
|
() => voiceDataUrlCache.get(voiceCacheKey)
|
||||||
)
|
)
|
||||||
|
const voiceTranscriptCacheKey = `voice-transcript:${message.localId}`
|
||||||
|
const [voiceTranscript, setVoiceTranscript] = useState<string | undefined>(
|
||||||
|
() => voiceTranscriptCache.get(voiceTranscriptCacheKey)
|
||||||
|
)
|
||||||
|
|
||||||
const formatTime = (timestamp: number): string => {
|
const formatTime = (timestamp: number): string => {
|
||||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
||||||
@@ -1604,6 +1612,37 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
}
|
}
|
||||||
}, [isVoice])
|
}, [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) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<div className="message-bubble system">
|
<div className="message-bubble system">
|
||||||
@@ -1762,34 +1801,57 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying
|
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 (
|
return (
|
||||||
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
<div className="voice-stack">
|
||||||
<button
|
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
||||||
className="voice-play-btn"
|
<button
|
||||||
onClick={(e) => {
|
className="voice-play-btn"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
handleToggle()
|
e.stopPropagation()
|
||||||
}}
|
handleToggle()
|
||||||
aria-label="播放语音"
|
}}
|
||||||
type="button"
|
aria-label="播放语音"
|
||||||
>
|
type="button"
|
||||||
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
|
>
|
||||||
</button>
|
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||||
<div className="voice-wave">
|
</button>
|
||||||
<span />
|
<div className="voice-wave">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</div>
|
<span />
|
||||||
<div className="voice-info">
|
</div>
|
||||||
<span className="voice-label">语音</span>
|
<div className="voice-info">
|
||||||
{durationText && <span className="voice-duration">{durationText}</span>}
|
<span className="voice-label">语音</span>
|
||||||
{voiceLoading && <span className="voice-loading">解码中...</span>}
|
{durationText && <span className="voice-duration">{durationText}</span>}
|
||||||
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
{voiceLoading && <span className="voice-loading">解码中...</span>}
|
||||||
{voiceError && <span className="voice-error">播放失败</span>}
|
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
||||||
|
{voiceError && <span className="voice-error">播放失败</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showTranscript && (
|
||||||
|
<div
|
||||||
|
className={`voice-transcript ${isSent ? 'sent' : 'received'}${voiceTranscriptError ? ' error' : ''}`}
|
||||||
|
onClick={handleTranscriptRetry}
|
||||||
|
title={voiceTranscriptError ? '点击重试语音转写' : undefined}
|
||||||
|
>
|
||||||
|
{transcriptDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,23 @@
|
|||||||
cursor: pointer;
|
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 {
|
.input-with-toggle {
|
||||||
position: relative;
|
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 {
|
.log-toggle-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
import { useThemeStore, themes } from '../stores/themeStore'
|
import { useThemeStore, themes } from '../stores/themeStore'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
@@ -7,15 +7,29 @@ import * as configService from '../services/config'
|
|||||||
import {
|
import {
|
||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
|
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'
|
} from 'lucide-react'
|
||||||
import './SettingsPage.scss'
|
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 }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
|
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
@@ -41,6 +55,12 @@ function SettingsPage() {
|
|||||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const [cachePath, setCachePath] = useState('')
|
const [cachePath, setCachePath] = useState('')
|
||||||
const [logEnabled, setLogEnabled] = useState(false)
|
const [logEnabled, setLogEnabled] = useState(false)
|
||||||
|
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||||
|
const [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 [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
@@ -102,6 +122,9 @@ function SettingsPage() {
|
|||||||
const savedLogEnabled = await configService.getLogEnabled()
|
const savedLogEnabled = await configService.getLogEnabled()
|
||||||
const savedImageXorKey = await configService.getImageXorKey()
|
const savedImageXorKey = await configService.getImageXorKey()
|
||||||
const savedImageAesKey = await configService.getImageAesKey()
|
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 (savedKey) setDecryptKey(savedKey)
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
@@ -112,6 +135,9 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
||||||
setLogEnabled(savedLogEnabled)
|
setLogEnabled(savedLogEnabled)
|
||||||
|
if (savedWhisperModelName) setWhisperModelName(savedWhisperModelName)
|
||||||
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
|
if (savedWhisperSource) setWhisperDownloadSource(savedWhisperSource)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', 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 () => {
|
const loadAppVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const version = await window.electronAPI.app.getVersion()
|
const version = await window.electronAPI.app.getVersion()
|
||||||
@@ -136,6 +176,20 @@ function SettingsPage() {
|
|||||||
return () => removeListener?.()
|
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 () => {
|
const handleCheckUpdate = async () => {
|
||||||
setIsCheckingUpdate(true)
|
setIsCheckingUpdate(true)
|
||||||
setUpdateInfo(null)
|
setUpdateInfo(null)
|
||||||
@@ -143,9 +197,9 @@ function SettingsPage() {
|
|||||||
const result = await window.electronAPI.app.checkForUpdates()
|
const result = await window.electronAPI.app.checkForUpdates()
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
setUpdateInfo(result)
|
setUpdateInfo(result)
|
||||||
showMessage(`发现新版本 ${result.version}`, true)
|
showMessage(`发现新版:${result.version}`, true)
|
||||||
} else {
|
} else {
|
||||||
showMessage('当前已是最新版本', true)
|
showMessage('当前已是最新版', true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showMessage(`检查更新失败: ${e}`, false)
|
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 () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -367,6 +475,9 @@ function SettingsPage() {
|
|||||||
} else {
|
} else {
|
||||||
await configService.setImageAesKey('')
|
await configService.setImageAesKey('')
|
||||||
}
|
}
|
||||||
|
await configService.setWhisperModelName(whisperModelName)
|
||||||
|
await configService.setWhisperModelDir(whisperModelDir)
|
||||||
|
await configService.setWhisperDownloadSource(whisperDownloadSource)
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|
||||||
showMessage('配置保存成功,正在测试连接...', true)
|
showMessage('配置保存成功,正在测试连接...', true)
|
||||||
@@ -387,7 +498,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClearConfig = async () => {
|
const handleClearConfig = async () => {
|
||||||
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。')
|
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?')
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
setIsLoadingState(true)
|
setIsLoadingState(true)
|
||||||
setLoading(true, '正在清除配置...')
|
setLoading(true, '正在清除配置...')
|
||||||
@@ -402,6 +513,12 @@ function SettingsPage() {
|
|||||||
setWxid('')
|
setWxid('')
|
||||||
setCachePath('')
|
setCachePath('')
|
||||||
setLogEnabled(false)
|
setLogEnabled(false)
|
||||||
|
setWhisperModelName('base')
|
||||||
|
setWhisperModelDir('')
|
||||||
|
setWhisperDownloadSource('tsinghua')
|
||||||
|
setWhisperModelStatus(null)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
setIsWhisperDownloading(false)
|
||||||
setDbConnected(false)
|
setDbConnected(false)
|
||||||
await window.electronAPI.window.openOnboardingWindow()
|
await window.electronAPI.window.openOnboardingWindow()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -608,16 +725,6 @@ function SettingsPage() {
|
|||||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
||||||
</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">
|
<div className="form-group">
|
||||||
<label>调试日志</label>
|
<label>调试日志</label>
|
||||||
<span className="form-hint">开启后写入 WCDB 调试日志,便于排查连接问题</span>
|
<span className="form-hint">开启后写入 WCDB 调试日志,便于排查连接问题</span>
|
||||||
@@ -650,12 +757,82 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
const renderWhisperTab = () => (
|
||||||
|
<div className="tab-content">
|
||||||
|
<p className="section-desc">语音解密后自动转写为文字</p>
|
||||||
|
<div className="form-group whisper-section">
|
||||||
|
<label>语音识别模型 (Whisper)</label>
|
||||||
|
<span className="form-hint">语音解密后自动转文字,模型越大越准确但下载更慢</span>
|
||||||
|
<div className="whisper-grid">
|
||||||
|
<div className="whisper-field">
|
||||||
|
<span className="field-label">模型</span>
|
||||||
|
<select
|
||||||
|
value={whisperModelName}
|
||||||
|
onChange={(e) => handleWhisperModelChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{whisperModels.map((model) => (
|
||||||
|
<option key={model.value} value={model.value}>{model.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="whisper-field">
|
||||||
|
<span className="field-label">下载源</span>
|
||||||
|
<select
|
||||||
|
value={whisperDownloadSource}
|
||||||
|
onChange={(e) => handleWhisperSourceChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{whisperSources.map((source) => (
|
||||||
|
<option key={source.value} value={source.value}>{source.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 ? '已下载' : '未下载'}
|
||||||
|
</span>
|
||||||
|
{whisperModelStatus?.path && <span className="path">{whisperModelStatus.path}</span>}
|
||||||
|
</div>
|
||||||
|
{isWhisperDownloading ? (
|
||||||
|
<div className="whisper-progress">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span>{whisperDownloadProgress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={handleDownloadWhisperModel}>
|
||||||
|
<Download size={16} /> 下载模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
const renderCacheTab = () => (
|
const renderCacheTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<p className="section-desc">管理应用缓存数据</p>
|
<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" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
||||||
<Trash2 size={16} /> 清除分析缓存
|
<Trash2 size={16} /> 清除分析缓存
|
||||||
@@ -664,8 +841,7 @@ function SettingsPage() {
|
|||||||
<Trash2 size={16} /> 清除图片缓存
|
<Trash2 size={16} /> 清除图片缓存
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
|
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
|
||||||
<Trash2 size={16} /> 清除所有缓存
|
<Trash2 size={16} /> 清除所有缓存</button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
||||||
@@ -690,7 +866,7 @@ function SettingsPage() {
|
|||||||
<div className="about-update">
|
<div className="about-update">
|
||||||
{updateInfo?.hasUpdate ? (
|
{updateInfo?.hasUpdate ? (
|
||||||
<>
|
<>
|
||||||
<p className="update-hint">新版本 v{updateInfo.version} 可用</p>
|
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<div className="download-progress">
|
<div className="download-progress">
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
@@ -747,7 +923,7 @@ function SettingsPage() {
|
|||||||
onClick={() => handleSelectWxid(opt.wxid)}
|
onClick={() => handleSelectWxid(opt.wxid)}
|
||||||
>
|
>
|
||||||
<span className="wxid-id">{opt.wxid}</span>
|
<span className="wxid-id">{opt.wxid}</span>
|
||||||
<span className="wxid-date">最后修改: {new Date(opt.modifiedTime).toLocaleString()}</span>
|
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -782,6 +958,7 @@ function SettingsPage() {
|
|||||||
<div className="settings-body">
|
<div className="settings-body">
|
||||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
|
{activeTab === 'whisper' && renderWhisperTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
</div>
|
</div>
|
||||||
@@ -790,3 +967,5 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPage
|
export default SettingsPage
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export const CONFIG_KEYS = {
|
|||||||
ONBOARDING_DONE: 'onboardingDone',
|
ONBOARDING_DONE: 'onboardingDone',
|
||||||
LLM_MODEL_PATH: 'llmModelPath',
|
LLM_MODEL_PATH: 'llmModelPath',
|
||||||
IMAGE_XOR_KEY: 'imageXorKey',
|
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
|
} as const
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
@@ -144,6 +147,39 @@ export async function setLlmModelPath(path: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
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> {
|
export async function clearConfig(): Promise<void> {
|
||||||
await config.clear()
|
await config.clear()
|
||||||
|
|||||||
6
src/types/electron.d.ts
vendored
6
src/types/electron.d.ts
vendored
@@ -95,6 +95,7 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getVoiceData: (sessionId: string, msgId: string) => 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: {
|
image: {
|
||||||
@@ -295,6 +296,11 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
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 {
|
export interface ExportOptions {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"electron/**/*.ts"
|
"electron/**/*.ts",
|
||||||
|
"electron/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export default defineConfig({
|
|||||||
external: [
|
external: [
|
||||||
'better-sqlite3',
|
'better-sqlite3',
|
||||||
'koffi',
|
'koffi',
|
||||||
'fsevents'
|
'fsevents',
|
||||||
|
'whisper-node',
|
||||||
|
'shelljs'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user