mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Merge branch 'dev'
合并
This commit is contained in:
26
.github/workflows/release.yml
vendored
26
.github/workflows/release.yml
vendored
@@ -25,7 +25,14 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
run: |
|
run: |
|
||||||
@@ -39,19 +46,19 @@ jobs:
|
|||||||
outputFile: "release-notes.md"
|
outputFile: "release-notes.md"
|
||||||
configurationJson: |
|
configurationJson: |
|
||||||
{
|
{
|
||||||
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
"template": "# v${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
||||||
"categories": [
|
"categories": [
|
||||||
{
|
{
|
||||||
"title": "## 新功能",
|
"title": "## 新功能",
|
||||||
"filter": { "pattern": "^feat:.*", "flags": "i" }
|
"filter": { "pattern": "^feat.*:.*", "flags": "i" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "## 修复",
|
"title": "## 修复",
|
||||||
"filter": { "pattern": "^fix:.*", "flags": "i" }
|
"filter": { "pattern": "^fix.*:.*", "flags": "i" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "## 性能与维护",
|
"title": "## 性能与维护",
|
||||||
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
|
"filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test).*:.*", "flags": "i" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ignore_labels": [],
|
"ignore_labels": [],
|
||||||
@@ -61,8 +68,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Check Changelog Content
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== RELEASE NOTES CONTENT START ==="
|
||||||
|
cat release-notes.md
|
||||||
|
echo "=== RELEASE NOTES CONTENT END ==="
|
||||||
|
|
||||||
- name: Package and Publish
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
|
npx electron-builder --publish always -c.releaseInfo.releaseNotesFile=release-notes.md
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
*info
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -438,8 +439,17 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getImageData(sessionId, msgId)
|
return chatService.getImageData(sessionId, msgId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||||
return chatService.getVoiceData(sessionId, msgId)
|
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||||
|
})
|
||||||
|
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||||
|
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => {
|
||||||
|
return chatService.getVoiceTranscript(sessionId, msgId, (text) => {
|
||||||
|
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
||||||
@@ -482,6 +492,50 @@ function registerIpcHandlers() {
|
|||||||
return analyticsService.getTimeDistribution()
|
return analyticsService.getTimeDistribution()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 缓存管理
|
||||||
|
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||||
|
return analyticsService.clearCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:clearImages', async () => {
|
||||||
|
const imageResult = await imageDecryptService.clearCache()
|
||||||
|
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||||
|
const errors = [imageResult, emojiResult]
|
||||||
|
.filter((result) => !result.success)
|
||||||
|
.map((result) => result.error)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, error: errors.join('; ') }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('cache:clearAll', async () => {
|
||||||
|
const [analyticsResult, imageResult] = await Promise.all([
|
||||||
|
analyticsService.clearCache(),
|
||||||
|
imageDecryptService.clearCache()
|
||||||
|
])
|
||||||
|
const chatResult = chatService.clearCaches()
|
||||||
|
const errors = [analyticsResult, imageResult, chatResult]
|
||||||
|
.filter((result) => !result.success)
|
||||||
|
.map((result) => result.error)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, error: errors.join('; ') }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('whisper:downloadModel', async (event) => {
|
||||||
|
return voiceTranscribeService.downloadModel((progress) => {
|
||||||
|
event.sender.send('whisper:downloadProgress', progress)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('whisper:getModelStatus', async () => {
|
||||||
|
return voiceTranscribeService.getModelStatus()
|
||||||
|
})
|
||||||
|
|
||||||
// 群聊分析相关
|
// 群聊分析相关
|
||||||
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||||
return groupAnalyticsService.getGroupChats()
|
return groupAnalyticsService.getGroupChats()
|
||||||
|
|||||||
@@ -106,7 +106,15 @@ 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, createTime?: number, serverId?: string | number) =>
|
||||||
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
|
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||||
|
getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId),
|
||||||
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
|
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||||
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +148,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 缓存管理
|
||||||
|
cache: {
|
||||||
|
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
|
||||||
|
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
|
||||||
|
clearAll: () => ipcRenderer.invoke('cache:clearAll')
|
||||||
|
},
|
||||||
|
|
||||||
// 群聊分析
|
// 群聊分析
|
||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
@@ -167,5 +182,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: () =>
|
||||||
|
ipcRenderer.invoke('whisper:downloadModel'),
|
||||||
|
getModelStatus: () =>
|
||||||
|
ipcRenderer.invoke('whisper:getModelStatus'),
|
||||||
|
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
|
||||||
|
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
@@ -324,7 +324,7 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getCacheFilePath(): string {
|
private getCacheFilePath(): string {
|
||||||
return join(app.getPath('userData'), 'analytics_cache.json')
|
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||||
@@ -528,6 +528,18 @@ class AnalyticsService {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
this.aggregateCache = null
|
||||||
|
this.fallbackAggregateCache = null
|
||||||
|
this.aggregatePromise = null
|
||||||
|
try {
|
||||||
|
await rm(this.getCacheFilePath(), { force: true })
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const analyticsService = new AnalyticsService()
|
export const analyticsService = new AnalyticsService()
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import * as http from 'http'
|
|||||||
import * as fzstd from 'fzstd'
|
import * as fzstd from 'fzstd'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { execFile } from 'child_process'
|
|
||||||
import { promisify } from 'util'
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
|
||||||
import { ConfigService } from './config'
|
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 +80,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()
|
||||||
@@ -1725,6 +1726,48 @@ class ChatService {
|
|||||||
return join(documentsPath, 'WeFlow', 'Emojis')
|
return join(documentsPath, 'WeFlow', 'Emojis')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearCaches(options?: { includeMessages?: boolean; includeContacts?: boolean; includeEmojis?: boolean }): { success: boolean; error?: string } {
|
||||||
|
const includeMessages = options?.includeMessages !== false
|
||||||
|
const includeContacts = options?.includeContacts !== false
|
||||||
|
const includeEmojis = options?.includeEmojis !== false
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (includeContacts) {
|
||||||
|
this.avatarCache.clear()
|
||||||
|
this.contactCacheService.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMessages) {
|
||||||
|
this.messageCacheService.clear()
|
||||||
|
this.voiceWavCache.clear()
|
||||||
|
this.voiceTranscriptCache.clear()
|
||||||
|
this.voiceTranscriptPending.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const state of this.hardlinkCache.values()) {
|
||||||
|
try {
|
||||||
|
state.db?.close()
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
this.hardlinkCache.clear()
|
||||||
|
|
||||||
|
if (includeEmojis) {
|
||||||
|
emojiCache.clear()
|
||||||
|
emojiDownloading.clear()
|
||||||
|
const emojiDir = this.getEmojiCacheDir()
|
||||||
|
try {
|
||||||
|
fs.rmSync(emojiDir, { recursive: true, force: true })
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, error: errors.join('; ') }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载并缓存表情包
|
* 下载并缓存表情包
|
||||||
*/
|
*/
|
||||||
@@ -2102,14 +2145,141 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVoiceData(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
|
/**
|
||||||
|
* getVoiceData (优化的 C++ 实现 + 文件缓存)
|
||||||
|
*/
|
||||||
|
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localId = parseInt(msgId, 10)
|
||||||
|
if (isNaN(localId)) {
|
||||||
|
return { success: false, error: '无效的消息ID' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件缓存
|
||||||
|
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||||
|
const cachedFile = this.getVoiceCacheFilePath(cacheKey)
|
||||||
|
if (existsSync(cachedFile)) {
|
||||||
|
try {
|
||||||
|
const wavData = readFileSync(cachedFile)
|
||||||
|
console.info('[ChatService][Voice] 使用缓存文件:', cachedFile)
|
||||||
|
return { success: true, data: wavData.toString('base64') }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChatService][Voice] 读取缓存失败:', e)
|
||||||
|
// 继续重新解密
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 确定 createTime 和 svrId
|
||||||
|
let msgCreateTime = createTime
|
||||||
|
let msgSvrId: string | number = serverId || 0
|
||||||
|
|
||||||
|
// 如果提供了传来的参数,验证其有效性
|
||||||
|
if (!msgCreateTime || msgCreateTime === 0) {
|
||||||
|
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||||
|
if (msgResult.success && msgResult.message) {
|
||||||
|
const msg = msgResult.message as any
|
||||||
|
msgCreateTime = msg.createTime || msg.create_time
|
||||||
|
// 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找)
|
||||||
|
if (!msgSvrId || msgSvrId === 0) {
|
||||||
|
msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msgCreateTime) {
|
||||||
|
return { success: false, error: '未找到消息时间戳' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 构建查找候选 (sessionId, myWxid)
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (sessionId) candidates.push(sessionId)
|
||||||
|
const myWxid = this.configService.get('myWxid') as string
|
||||||
|
if (myWxid && !candidates.includes(myWxid)) {
|
||||||
|
candidates.push(myWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 3. 调用 C++ 接口获取语音 (Hex)
|
||||||
|
const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId)
|
||||||
|
if (!voiceRes.success || !voiceRes.hex) {
|
||||||
|
return { success: false, error: voiceRes.error || '未找到语音数据' }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Hex 转 Buffer (Silk)
|
||||||
|
const silkData = Buffer.from(voiceRes.hex, 'hex')
|
||||||
|
|
||||||
|
// 5. 使用 silk-wasm 解码
|
||||||
|
try {
|
||||||
|
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||||
|
if (!pcmData) {
|
||||||
|
return { success: false, error: 'Silk 解码失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCM -> WAV
|
||||||
|
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||||
|
|
||||||
|
// 保存到文件缓存
|
||||||
|
try {
|
||||||
|
this.saveVoiceCache(cacheKey, wavData)
|
||||||
|
console.info('[ChatService][Voice] 已保存缓存:', cachedFile)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChatService][Voice] 保存缓存失败:', e)
|
||||||
|
// 不影响返回
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 WAV 数据 (内存缓存)
|
||||||
|
this.cacheVoiceWav(cacheKey, wavData)
|
||||||
|
|
||||||
|
return { success: true, data: wavData.toString('base64') }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChatService][Voice] decoding error:', e)
|
||||||
|
return { success: false, error: '语音解码失败: ' + String(e) }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ChatService: getVoiceData 失败:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查语音是否已有缓存
|
||||||
|
*/
|
||||||
|
async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> {
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||||
|
|
||||||
|
// 1. 检查内存缓存
|
||||||
|
const inMemory = this.voiceWavCache.get(cacheKey)
|
||||||
|
if (inMemory) {
|
||||||
|
return { success: true, hasCache: true, data: inMemory.toString('base64') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查文件缓存
|
||||||
|
const cachedFile = this.getVoiceCacheFilePath(cacheKey)
|
||||||
|
if (existsSync(cachedFile)) {
|
||||||
|
const wavData = readFileSync(cachedFile)
|
||||||
|
this.cacheVoiceWav(cacheKey, wavData) // 回甜内存
|
||||||
|
return { success: true, hasCache: true, data: wavData.toString('base64') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, hasCache: false }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, hasCache: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVoiceData_Legacy(sessionId: string, msgId: string): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const localId = parseInt(msgId, 10)
|
const localId = parseInt(msgId, 10)
|
||||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||||
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' }
|
||||||
const msg = msgResult.message
|
const msg = msgResult.message
|
||||||
if (msg.isSend === 1) {
|
if (msg.isSend === 1) {
|
||||||
return { success: false, error: '暂不支持解密自己发送的语音' }
|
console.info('[ChatService][Voice] self-sent voice, continue decrypt flow')
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = this.getVoiceLookupCandidates(sessionId, msg)
|
const candidates = this.getVoiceLookupCandidates(sessionId, msg)
|
||||||
@@ -2140,12 +2310,10 @@ class ChatService {
|
|||||||
for (const dbPath of (mediaDbs.data || [])) {
|
for (const dbPath of (mediaDbs.data || [])) {
|
||||||
const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
|
const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
|
||||||
if (!voiceTable) {
|
if (!voiceTable) {
|
||||||
console.warn('[ChatService][Voice] voice table not found', dbPath)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable)
|
const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable)
|
||||||
if (!columns) {
|
if (!columns) {
|
||||||
console.warn('[ChatService][Voice] voice columns not found', { dbPath, voiceTable })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -2186,50 +2354,44 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (silkData) break
|
if (silkData) break
|
||||||
|
|
||||||
|
// 策略 3: 只使用 CreateTime (兜底)
|
||||||
|
if (!silkData && columns.createTimeColumn) {
|
||||||
|
const whereClause = `${columns.createTimeColumn} = ${msg.createTime}`
|
||||||
|
const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1`
|
||||||
|
const result = await wcdbService.execQuery('media', dbPath, sql)
|
||||||
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
|
const raw = result.rows[0]?.data
|
||||||
|
const decoded = this.decodeVoiceBlob(raw)
|
||||||
|
if (decoded && decoded.length > 0) {
|
||||||
|
console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length })
|
||||||
|
silkData = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (silkData) break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!silkData) return { success: false, error: '未找到语音数据' }
|
if (!silkData) return { success: false, error: '未找到语音数据' }
|
||||||
|
|
||||||
// 4. 解码 Silk -> PCM -> WAV
|
// 4. 使用 silk-wasm 解码
|
||||||
const resourcesPath = app.isPackaged
|
|
||||||
? join(process.resourcesPath, 'resources')
|
|
||||||
: join(app.getAppPath(), 'resources')
|
|
||||||
const decoderPath = join(resourcesPath, 'silk_v3_decoder.exe')
|
|
||||||
|
|
||||||
if (!existsSync(decoderPath)) {
|
|
||||||
return { success: false, error: '找不到语音解码器 (silk_v3_decoder.exe)' }
|
|
||||||
}
|
|
||||||
console.info('[ChatService][Voice] decoder path', decoderPath)
|
|
||||||
|
|
||||||
const tempDir = app.getPath('temp')
|
|
||||||
const silkFile = join(tempDir, `voice_${msgId}.silk`)
|
|
||||||
const pcmFile = join(tempDir, `voice_${msgId}.pcm`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(silkFile, silkData)
|
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||||
// 执行解码: silk_v3_decoder.exe <silk> <pcm> -Fs_API 24000
|
if (!pcmData) {
|
||||||
console.info('[ChatService][Voice] executing decoder:', decoderPath, [silkFile, pcmFile])
|
return { success: false, error: 'Silk 解码失败' }
|
||||||
const { stdout, stderr } = await execFileAsync(
|
|
||||||
decoderPath,
|
|
||||||
[silkFile, pcmFile, '-Fs_API', '24000'],
|
|
||||||
{ cwd: dirname(decoderPath) }
|
|
||||||
)
|
|
||||||
if (stdout && stdout.trim()) console.info('[ChatService][Voice] decoder stdout:', stdout)
|
|
||||||
if (stderr && stderr.trim()) console.warn('[ChatService][Voice] decoder stderr:', stderr)
|
|
||||||
|
|
||||||
if (!existsSync(pcmFile)) {
|
|
||||||
return { success: false, error: '语音解码失败' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pcmData = readFileSync(pcmFile)
|
// PCM -> WAV
|
||||||
const wavHeader = this.createWavHeader(pcmData.length, 24000, 1) // 微信语音通常 24kHz
|
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||||
const wavData = Buffer.concat([wavHeader, pcmData])
|
|
||||||
|
// 缓存 WAV 数据 (内存缓存)
|
||||||
|
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 {
|
} catch (e) {
|
||||||
// 清理临时文件
|
console.error('[ChatService][Voice] decoding error:', e)
|
||||||
try { if (existsSync(silkFile)) unlinkSync(silkFile) } catch { }
|
return { success: false, error: '语音解码失败: ' + String(e) }
|
||||||
try { if (existsSync(pcmFile)) unlinkSync(pcmFile) } catch { }
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: getVoiceData 失败:', e)
|
console.error('ChatService: getVoiceData 失败:', e)
|
||||||
@@ -2237,7 +2399,47 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createWavHeader(pcmLength: number, sampleRate: number = 24000, channels: number = 1): Buffer {
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解码 Silk 数据为 PCM (silk-wasm)
|
||||||
|
*/
|
||||||
|
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
let wasmPath: string
|
||||||
|
if (app.isPackaged) {
|
||||||
|
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
|
if (!existsSync(wasmPath)) {
|
||||||
|
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(wasmPath)) {
|
||||||
|
console.error('[ChatService][Voice] silk.wasm not found at:', wasmPath)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const silkWasm = require('silk-wasm')
|
||||||
|
if (!silkWasm || !silkWasm.decode) {
|
||||||
|
console.error('[ChatService][Voice] silk-wasm module invalid')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await silkWasm.decode(silkData, sampleRate)
|
||||||
|
return Buffer.from(result.data)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChatService][Voice] internal decode error:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 WAV 文件 Buffer
|
||||||
|
*/
|
||||||
|
private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer {
|
||||||
|
const pcmLength = pcmData.length
|
||||||
const header = Buffer.alloc(44)
|
const header = Buffer.alloc(44)
|
||||||
header.write('RIFF', 0)
|
header.write('RIFF', 0)
|
||||||
header.writeUInt32LE(36 + pcmLength, 4)
|
header.writeUInt32LE(36 + pcmLength, 4)
|
||||||
@@ -2252,7 +2454,109 @@ class ChatService {
|
|||||||
header.writeUInt16LE(16, 34)
|
header.writeUInt16LE(16, 34)
|
||||||
header.write('data', 36)
|
header.write('data', 36)
|
||||||
header.writeUInt32LE(pcmLength, 40)
|
header.writeUInt32LE(pcmLength, 40)
|
||||||
return header
|
return Buffer.concat([header, pcmData])
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVoiceTranscript(
|
||||||
|
sessionId: string,
|
||||||
|
msgId: string,
|
||||||
|
onPartial?: (text: string) => void
|
||||||
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||||
|
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||||
|
const cached = this.voiceTranscriptCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return { success: true, transcript: cached }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = this.voiceTranscriptPending.get(cacheKey)
|
||||||
|
if (pending) {
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
try {
|
||||||
|
let wavData = this.voiceWavCache.get(cacheKey)
|
||||||
|
if (!wavData) {
|
||||||
|
// 获取消息详情以拿到 createTime 和 serverId
|
||||||
|
let cTime: number | undefined
|
||||||
|
let sId: string | number | undefined
|
||||||
|
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
|
||||||
|
if (msgResult.success && msgResult.message) {
|
||||||
|
cTime = msgResult.message.createTime
|
||||||
|
sId = msgResult.message.serverId
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId)
|
||||||
|
if (!voiceResult.success || !voiceResult.data) {
|
||||||
|
return { success: false, error: voiceResult.error || '语音解码失败' }
|
||||||
|
}
|
||||||
|
wavData = Buffer.from(voiceResult.data, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
|
||||||
|
onPartial?.(text)
|
||||||
|
})
|
||||||
|
if (result.success && result.transcript) {
|
||||||
|
this.cacheVoiceTranscript(cacheKey, result.transcript)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
} finally {
|
||||||
|
this.voiceTranscriptPending.delete(cacheKey)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.voiceTranscriptPending.set(cacheKey, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private getVoiceCacheKey(sessionId: string, msgId: string): string {
|
||||||
|
return `${sessionId}_${msgId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
|
||||||
|
this.voiceWavCache.set(cacheKey, wavData)
|
||||||
|
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) {
|
||||||
|
const oldestKey = this.voiceWavCache.keys().next().value
|
||||||
|
if (oldestKey) this.voiceWavCache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取语音缓存文件路径
|
||||||
|
*/
|
||||||
|
private getVoiceCacheFilePath(cacheKey: string): string {
|
||||||
|
const cachePath = this.configService.get('cachePath') as string | undefined
|
||||||
|
let baseDir: string
|
||||||
|
if (cachePath && cachePath.trim()) {
|
||||||
|
baseDir = join(cachePath, 'Voices')
|
||||||
|
} else {
|
||||||
|
const documentsPath = app.getPath('documents')
|
||||||
|
baseDir = join(documentsPath, 'WeFlow', 'Voices')
|
||||||
|
}
|
||||||
|
if (!existsSync(baseDir)) {
|
||||||
|
mkdirSync(baseDir, { recursive: true })
|
||||||
|
}
|
||||||
|
return join(baseDir, `${cacheKey}.wav`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存语音到文件缓存
|
||||||
|
*/
|
||||||
|
private saveVoiceCache(cacheKey: string, wavData: Buffer): void {
|
||||||
|
const filePath = this.getVoiceCacheFilePath(cacheKey)
|
||||||
|
writeFileSync(filePath, wavData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
|
||||||
|
this.voiceTranscriptCache.set(cacheKey, transcript)
|
||||||
|
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
|
||||||
|
const oldestKey = this.voiceTranscriptCache.keys().next().value
|
||||||
|
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ interface ConfigSchema {
|
|||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
|
whisperModelName: string
|
||||||
|
whisperModelDir: string
|
||||||
|
whisperDownloadSource: string
|
||||||
|
autoTranscribeVoice: boolean
|
||||||
|
transcribeLanguages: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -42,7 +47,12 @@ 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',
|
||||||
|
autoTranscribeVoice: false,
|
||||||
|
transcribeLanguages: ['zh']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
@@ -15,7 +15,7 @@ export class ContactCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: join(app.getPath('documents'), 'WeFlow')
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -72,4 +72,13 @@ export class ContactCacheService {
|
|||||||
console.error('ContactCacheService: 保存缓存失败', error)
|
console.error('ContactCacheService: 保存缓存失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache = {}
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
import { chatService } from './chatService'
|
||||||
|
|
||||||
// ChatLab 格式类型定义
|
// ChatLab 格式类型定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -64,6 +67,15 @@ export interface ExportOptions {
|
|||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
|
exportImages?: boolean
|
||||||
|
exportVoices?: boolean
|
||||||
|
exportEmojis?: boolean
|
||||||
|
exportVoiceAsText?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaExportItem {
|
||||||
|
relativePath: string
|
||||||
|
kind: 'image' | 'voice' | 'emoji'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
@@ -216,6 +228,7 @@ class ExportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析消息内容为可读文本
|
* 解析消息内容为可读文本
|
||||||
|
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
||||||
*/
|
*/
|
||||||
private parseMessageContent(content: string, localType: number): string | null {
|
private parseMessageContent(content: string, localType: number): string | null {
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
@@ -224,7 +237,7 @@ class ExportService {
|
|||||||
case 1:
|
case 1:
|
||||||
return this.stripSenderPrefix(content)
|
return this.stripSenderPrefix(content)
|
||||||
case 3: return '[图片]'
|
case 3: return '[图片]'
|
||||||
case 34: return '[语音消息]'
|
case 34: return '[语音消息]' // 占位符,导出时会替换为转文字结果
|
||||||
case 42: return '[名片]'
|
case 42: return '[名片]'
|
||||||
case 43: return '[视频]'
|
case 43: return '[视频]'
|
||||||
case 47: return '[动画表情]'
|
case 47: return '[动画表情]'
|
||||||
@@ -235,6 +248,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
case 50: return this.parseVoipMessage(content)
|
case 50: return this.parseVoipMessage(content)
|
||||||
case 10000: return this.cleanSystemMessage(content)
|
case 10000: return this.cleanSystemMessage(content)
|
||||||
|
case 266287972401: return this.cleanSystemMessage(content) // 拍一拍
|
||||||
default:
|
default:
|
||||||
if (content.includes('<type>57</type>')) {
|
if (content.includes('<type>57</type>')) {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
@@ -258,9 +272,41 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cleanSystemMessage(content: string): string {
|
private cleanSystemMessage(content: string): string {
|
||||||
|
if (!content) return '[系统消息]'
|
||||||
|
|
||||||
|
// 先尝试提取特定的系统消息内容
|
||||||
|
// 1. 提取 sysmsg 中的文本内容
|
||||||
|
const sysmsgTextMatch = /<sysmsg[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
|
||||||
|
if (sysmsgTextMatch) {
|
||||||
|
content = sysmsgTextMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取 revokemsg 撤回消息
|
||||||
|
const revokeMatch = /<replacemsg><!\[CDATA\[(.*?)\]\]><\/replacemsg>/i.exec(content)
|
||||||
|
if (revokeMatch) {
|
||||||
|
return revokeMatch[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 提取 pat 拍一拍消息
|
||||||
|
const patMatch = /<template><!\[CDATA\[(.*?)\]\]><\/template>/i.exec(content)
|
||||||
|
if (patMatch) {
|
||||||
|
// 移除模板变量占位符
|
||||||
|
return patMatch[1]
|
||||||
|
.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
||||||
|
const varMatch = new RegExp(`<${varName}><!\\\[CDATA\\\[([^\]]*)\\\]\\\]><\/${varName}>`, 'i').exec(content)
|
||||||
|
return varMatch ? varMatch[1] : ''
|
||||||
|
})
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 处理 CDATA 内容
|
||||||
|
content = content.replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '')
|
||||||
|
|
||||||
|
// 5. 移除所有 XML 标签
|
||||||
return content
|
return content
|
||||||
.replace(/<img[^>]*>/gi, '')
|
.replace(/<img[^>]*>/gi, '')
|
||||||
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
|
.replace(/<\/?[a-zA-Z0-9_:]+[^>]*>/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim() || '[系统消息]'
|
.trim() || '[系统消息]'
|
||||||
}
|
}
|
||||||
@@ -318,7 +364,6 @@ class ExportService {
|
|||||||
|
|
||||||
return `[${callType}]`
|
return `[${callType}]`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ExportService] Failed to parse VOIP message:', e)
|
|
||||||
return '[通话]'
|
return '[通话]'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +401,348 @@ class ExportService {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出媒体文件到指定目录
|
||||||
|
*/
|
||||||
|
private async exportMediaForMessage(
|
||||||
|
msg: any,
|
||||||
|
sessionId: string,
|
||||||
|
mediaDir: string,
|
||||||
|
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
||||||
|
): Promise<MediaExportItem | null> {
|
||||||
|
const localType = msg.localType
|
||||||
|
|
||||||
|
// 图片消息
|
||||||
|
if (localType === 3 && options.exportImages) {
|
||||||
|
const result = await this.exportImage(msg, sessionId, mediaDir)
|
||||||
|
if (result) {
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音消息
|
||||||
|
if (localType === 34) {
|
||||||
|
// 如果开启了语音转文字,优先转文字(不导出语音文件)
|
||||||
|
if (options.exportVoiceAsText) {
|
||||||
|
return null // 转文字逻辑在消息内容处理中完成
|
||||||
|
}
|
||||||
|
// 否则导出语音文件
|
||||||
|
if (options.exportVoices) {
|
||||||
|
return this.exportVoice(msg, sessionId, mediaDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画表情
|
||||||
|
if (localType === 47 && options.exportEmojis) {
|
||||||
|
const result = await this.exportEmoji(msg, sessionId, mediaDir)
|
||||||
|
if (result) {
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出图片文件
|
||||||
|
*/
|
||||||
|
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||||
|
try {
|
||||||
|
const imagesDir = path.join(mediaDir, 'media', 'images')
|
||||||
|
if (!fs.existsSync(imagesDir)) {
|
||||||
|
fs.mkdirSync(imagesDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用消息对象中已提取的字段
|
||||||
|
const imageMd5 = msg.imageMd5
|
||||||
|
const imageDatName = msg.imageDatName
|
||||||
|
|
||||||
|
if (!imageMd5 && !imageDatName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await imageDecryptService.decryptImage({
|
||||||
|
sessionId,
|
||||||
|
imageMd5,
|
||||||
|
imageDatName,
|
||||||
|
force: false // 先尝试缩略图
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success || !result.localPath) {
|
||||||
|
// 尝试获取缩略图
|
||||||
|
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||||
|
sessionId,
|
||||||
|
imageMd5,
|
||||||
|
imageDatName
|
||||||
|
})
|
||||||
|
if (!thumbResult.success || !thumbResult.localPath) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
result.localPath = thumbResult.localPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 data URL 或 file URL 获取实际路径
|
||||||
|
let sourcePath = result.localPath
|
||||||
|
if (sourcePath.startsWith('data:')) {
|
||||||
|
// 是 data URL,需要保存为文件
|
||||||
|
const base64Data = sourcePath.split(',')[1]
|
||||||
|
const ext = this.getExtFromDataUrl(sourcePath)
|
||||||
|
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
||||||
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
|
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
relativePath: `media/images/${fileName}`,
|
||||||
|
kind: 'image'
|
||||||
|
}
|
||||||
|
} else if (sourcePath.startsWith('file://')) {
|
||||||
|
sourcePath = fileURLToPath(sourcePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制文件
|
||||||
|
if (fs.existsSync(sourcePath)) {
|
||||||
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
|
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
|
||||||
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(sourcePath, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
relativePath: `media/images/${fileName}`,
|
||||||
|
kind: 'image'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出语音文件
|
||||||
|
*/
|
||||||
|
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||||
|
try {
|
||||||
|
const voicesDir = path.join(mediaDir, 'media', 'voices')
|
||||||
|
if (!fs.existsSync(voicesDir)) {
|
||||||
|
fs.mkdirSync(voicesDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgId = String(msg.localId)
|
||||||
|
const fileName = `voice_${msgId}.wav`
|
||||||
|
const destPath = path.join(voicesDir, fileName)
|
||||||
|
|
||||||
|
// 如果已存在则跳过
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
return {
|
||||||
|
relativePath: `media/voices/${fileName}`,
|
||||||
|
kind: 'voice'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 chatService 获取语音数据
|
||||||
|
const voiceResult = await chatService.getVoiceData(sessionId, msgId)
|
||||||
|
if (!voiceResult.success || !voiceResult.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceResult.data 是 base64 编码的 wav 数据
|
||||||
|
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||||
|
fs.writeFileSync(destPath, wavBuffer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
relativePath: `media/voices/${fileName}`,
|
||||||
|
kind: 'voice'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转写语音为文字
|
||||||
|
*/
|
||||||
|
private async transcribeVoice(sessionId: string, msgId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const transcript = await chatService.getVoiceTranscript(sessionId, msgId)
|
||||||
|
if (transcript.success && transcript.transcript) {
|
||||||
|
return `[语音转文字] ${transcript.transcript}`
|
||||||
|
}
|
||||||
|
return '[语音消息 - 转文字失败]'
|
||||||
|
} catch (e) {
|
||||||
|
return '[语音消息 - 转文字失败]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出表情文件
|
||||||
|
*/
|
||||||
|
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||||
|
try {
|
||||||
|
const emojisDir = path.join(mediaDir, 'media', 'emojis')
|
||||||
|
if (!fs.existsSync(emojisDir)) {
|
||||||
|
fs.mkdirSync(emojisDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用消息对象中已提取的字段
|
||||||
|
const emojiUrl = msg.emojiCdnUrl
|
||||||
|
const emojiMd5 = msg.emojiMd5
|
||||||
|
|
||||||
|
if (!emojiUrl && !emojiMd5) {
|
||||||
|
console.log('[ExportService] 表情消息缺少 url 和 md5, localId:', msg.localId, 'content:', msg.content?.substring(0, 200))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ExportService] 导出表情:', { localId: msg.localId, emojiMd5, emojiUrl: emojiUrl?.substring(0, 100) })
|
||||||
|
|
||||||
|
const key = emojiMd5 || String(msg.localId)
|
||||||
|
// 根据 URL 判断扩展名
|
||||||
|
let ext = '.gif'
|
||||||
|
if (emojiUrl) {
|
||||||
|
if (emojiUrl.includes('.png')) ext = '.png'
|
||||||
|
else if (emojiUrl.includes('.jpg') || emojiUrl.includes('.jpeg')) ext = '.jpg'
|
||||||
|
}
|
||||||
|
const fileName = `${key}${ext}`
|
||||||
|
const destPath = path.join(emojisDir, fileName)
|
||||||
|
|
||||||
|
// 如果已存在则跳过
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
return {
|
||||||
|
relativePath: `media/emojis/${fileName}`,
|
||||||
|
kind: 'emoji'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载表情
|
||||||
|
if (emojiUrl) {
|
||||||
|
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
||||||
|
if (downloaded) {
|
||||||
|
return {
|
||||||
|
relativePath: `media/emojis/${fileName}`,
|
||||||
|
kind: 'emoji'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从消息内容提取图片 MD5
|
||||||
|
*/
|
||||||
|
private extractImageMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const match = /md5="([^"]+)"/i.exec(content)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从消息内容提取图片 DAT 文件名
|
||||||
|
*/
|
||||||
|
private extractImageDatName(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
// 尝试从 cdnthumburl 或其他字段提取
|
||||||
|
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content)
|
||||||
|
if (urlMatch) {
|
||||||
|
const urlParts = urlMatch[1].split('/')
|
||||||
|
const last = urlParts[urlParts.length - 1]
|
||||||
|
if (last && last.includes('_')) {
|
||||||
|
return last.split('_')[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从消息内容提取表情 URL
|
||||||
|
*/
|
||||||
|
private extractEmojiUrl(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
// 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"]
|
||||||
|
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
// 解码 & 等实体
|
||||||
|
let url = attrMatch[1].replace(/&/g, '&')
|
||||||
|
// URL 解码
|
||||||
|
try {
|
||||||
|
if (url.includes('%')) {
|
||||||
|
url = decodeURIComponent(url)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// 备用:尝试 XML 标签形式
|
||||||
|
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
|
||||||
|
return tagMatch?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从消息内容提取表情 MD5
|
||||||
|
*/
|
||||||
|
private extractEmojiMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 data URL 获取扩展名
|
||||||
|
*/
|
||||||
|
private getExtFromDataUrl(dataUrl: string): string {
|
||||||
|
if (dataUrl.includes('image/png')) return '.png'
|
||||||
|
if (dataUrl.includes('image/gif')) return '.gif'
|
||||||
|
if (dataUrl.includes('image/webp')) return '.webp'
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
private async downloadFile(url: string, destPath: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
const request = protocol.get(url, { timeout: 30000 }, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
const redirectUrl = response.headers.location
|
||||||
|
if (redirectUrl) {
|
||||||
|
this.downloadFile(redirectUrl, destPath).then(resolve)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fileStream = fs.createWriteStream(destPath)
|
||||||
|
response.pipe(fileStream)
|
||||||
|
fileStream.on('finish', () => {
|
||||||
|
fileStream.close()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
fileStream.on('error', () => {
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
request.on('error', () => resolve(false))
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async collectMessages(
|
private async collectMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
@@ -393,6 +780,7 @@ class ExportService {
|
|||||||
const senderUsername = row.sender_username || ''
|
const senderUsername = row.sender_username || ''
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
||||||
const isSend = parseInt(isSendRaw, 10) === 1
|
const isSend = parseInt(isSendRaw, 10) === 1
|
||||||
|
const localId = parseInt(row.local_id || row.localId || '0', 10)
|
||||||
|
|
||||||
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||||
const memberInfo = await this.getContactInfo(actualSender)
|
const memberInfo = await this.getContactInfo(actualSender)
|
||||||
@@ -406,12 +794,33 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取媒体相关字段
|
||||||
|
let imageMd5: string | undefined
|
||||||
|
let imageDatName: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
|
||||||
|
if (localType === 3 && content) {
|
||||||
|
// 图片消息
|
||||||
|
imageMd5 = this.extractImageMd5(content)
|
||||||
|
imageDatName = this.extractImageDatName(content)
|
||||||
|
} else if (localType === 47 && content) {
|
||||||
|
// 动画表情
|
||||||
|
emojiCdnUrl = this.extractEmojiUrl(content)
|
||||||
|
emojiMd5 = this.extractEmojiMd5(content)
|
||||||
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
|
localId,
|
||||||
createTime,
|
createTime,
|
||||||
localType,
|
localType,
|
||||||
content,
|
content,
|
||||||
senderUsername: actualSender,
|
senderUsername: actualSender,
|
||||||
isSend
|
isSend,
|
||||||
|
imageMd5,
|
||||||
|
imageDatName,
|
||||||
|
emojiCdnUrl,
|
||||||
|
emojiMd5
|
||||||
})
|
})
|
||||||
|
|
||||||
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
if (firstTime === null || createTime < firstTime) firstTime = createTime
|
||||||
@@ -653,6 +1062,31 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成通用的导出元数据 (参考 ChatLab 格式)
|
||||||
|
*/
|
||||||
|
private getExportMeta(
|
||||||
|
sessionId: string,
|
||||||
|
sessionInfo: { displayName: string },
|
||||||
|
isGroup: boolean,
|
||||||
|
sessionAvatar?: string
|
||||||
|
): { chatlab: ChatLabHeader; meta: ChatLabMeta } {
|
||||||
|
return {
|
||||||
|
chatlab: {
|
||||||
|
version: '0.0.2',
|
||||||
|
exportedAt: Math.floor(Date.now() / 1000),
|
||||||
|
generator: 'WeFlow'
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
name: sessionInfo.displayName,
|
||||||
|
platform: 'wechat',
|
||||||
|
type: isGroup ? 'group' : 'private',
|
||||||
|
...(isGroup && { groupId: sessionId }),
|
||||||
|
...(sessionAvatar && { groupAvatar: sessionAvatar })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出单个会话为 ChatLab 格式
|
* 导出单个会话为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
@@ -693,21 +1127,29 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
const chatLabMessages: ChatLabMessage[] = []
|
||||||
|
for (const msg of allMessages) {
|
||||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||||
platformId: msg.senderUsername,
|
platformId: msg.senderUsername,
|
||||||
accountName: msg.senderUsername,
|
accountName: msg.senderUsername,
|
||||||
groupNickname: undefined
|
groupNickname: undefined
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
|
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||||
|
// 如果是语音消息且开启了转文字
|
||||||
|
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||||
|
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||||
|
}
|
||||||
|
|
||||||
|
chatLabMessages.push({
|
||||||
sender: msg.senderUsername,
|
sender: msg.senderUsername,
|
||||||
accountName: memberInfo.accountName,
|
accountName: memberInfo.accountName,
|
||||||
groupNickname: memberInfo.groupNickname,
|
groupNickname: memberInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.convertMessageType(msg.localType, msg.content),
|
type: this.convertMessageType(msg.localType, msg.content),
|
||||||
content: this.parseMessageContent(msg.content, msg.localType)
|
content: content
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const avatarMap = options.exportAvatars
|
const avatarMap = options.exportAvatars
|
||||||
? await this.exportAvatars(
|
? await this.exportAvatars(
|
||||||
@@ -727,19 +1169,11 @@ class ExportService {
|
|||||||
return avatar ? { ...info.member, avatar } : info.member
|
return avatar ? { ...info.member, avatar } : info.member
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar)
|
||||||
|
|
||||||
const chatLabExport: ChatLabExport = {
|
const chatLabExport: ChatLabExport = {
|
||||||
chatlab: {
|
chatlab,
|
||||||
version: '0.0.1',
|
meta,
|
||||||
exportedAt: Math.floor(Date.now() / 1000),
|
|
||||||
generator: 'WeFlow'
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
name: sessionInfo.displayName,
|
|
||||||
platform: 'wechat',
|
|
||||||
type: isGroup ? 'group' : 'private',
|
|
||||||
...(isGroup && { groupId: sessionId }),
|
|
||||||
...(sessionAvatar && { groupAvatar: sessionAvatar })
|
|
||||||
},
|
|
||||||
members,
|
members,
|
||||||
messages: chatLabMessages
|
messages: chatLabMessages
|
||||||
}
|
}
|
||||||
@@ -778,7 +1212,6 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ExportService: 导出失败:', e)
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,7 +1274,11 @@ class ExportService {
|
|||||||
phase: 'writing'
|
phase: 'writing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailedExport = {
|
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
|
|
||||||
|
const detailedExport: any = {
|
||||||
|
chatlab,
|
||||||
|
meta,
|
||||||
session: {
|
session: {
|
||||||
wxid: sessionId,
|
wxid: sessionId,
|
||||||
nickname: sessionInfo.displayName,
|
nickname: sessionInfo.displayName,
|
||||||
@@ -889,7 +1326,285 @@ class ExportService {
|
|||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ExportService: 导出失败:', e)
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出单个会话为 Excel 格式(参考 echotrace 格式)
|
||||||
|
*/
|
||||||
|
async exportSessionToExcel(
|
||||||
|
sessionId: string,
|
||||||
|
outputPath: string,
|
||||||
|
options: ExportOptions,
|
||||||
|
onProgress?: (progress: ExportProgress) => void
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const cleanedMyWxid = conn.cleanedWxid
|
||||||
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
|
|
||||||
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
|
||||||
|
// 获取会话的备注信息
|
||||||
|
const sessionContact = await wcdbService.getContact(sessionId)
|
||||||
|
const sessionRemark = sessionContact.success && sessionContact.contact?.remark ? sessionContact.contact.remark : ''
|
||||||
|
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionId
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 0,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'preparing'
|
||||||
|
})
|
||||||
|
|
||||||
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 30,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建 Excel 工作簿
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
workbook.creator = 'WeFlow'
|
||||||
|
workbook.created = new Date()
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('聊天记录')
|
||||||
|
|
||||||
|
let currentRow = 1
|
||||||
|
|
||||||
|
// 第一行:会话信息标题
|
||||||
|
worksheet.mergeCells(currentRow, 1, currentRow, 8)
|
||||||
|
const titleCell = worksheet.getCell(currentRow, 1)
|
||||||
|
titleCell.value = '会话信息'
|
||||||
|
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
|
||||||
|
worksheet.getRow(currentRow).height = 25
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
// 第二行:会话详细信息
|
||||||
|
worksheet.getCell(currentRow, 1).value = '微信ID'
|
||||||
|
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(currentRow, 2, currentRow, 3)
|
||||||
|
worksheet.getCell(currentRow, 2).value = sessionId
|
||||||
|
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
|
||||||
|
|
||||||
|
worksheet.getCell(currentRow, 4).value = '昵称'
|
||||||
|
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(currentRow, 5).value = sessionNickname
|
||||||
|
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
worksheet.getCell(currentRow, 6).value = '备注'
|
||||||
|
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(currentRow, 7, currentRow, 8)
|
||||||
|
worksheet.getCell(currentRow, 7).value = sessionRemark
|
||||||
|
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
|
||||||
|
}
|
||||||
|
worksheet.getRow(currentRow).height = 20
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
// 第三行:导出元数据
|
||||||
|
const { chatlab, meta: exportMeta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
|
worksheet.getCell(currentRow, 1).value = '导出工具'
|
||||||
|
worksheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(currentRow, 2).value = chatlab.generator
|
||||||
|
worksheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
worksheet.getCell(currentRow, 3).value = '导出版本'
|
||||||
|
worksheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(currentRow, 4).value = chatlab.version
|
||||||
|
worksheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
worksheet.getCell(currentRow, 5).value = '平台'
|
||||||
|
worksheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(currentRow, 6).value = exportMeta.platform
|
||||||
|
worksheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
worksheet.getCell(currentRow, 7).value = '导出时间'
|
||||||
|
worksheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(currentRow, 8).value = this.formatTimestamp(chatlab.exportedAt)
|
||||||
|
worksheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
|
||||||
|
|
||||||
|
worksheet.getRow(currentRow).height = 20
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
// 表头行
|
||||||
|
const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
|
||||||
|
const headerRow = worksheet.getRow(currentRow)
|
||||||
|
headerRow.height = 22
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const cell = headerRow.getCell(index + 1)
|
||||||
|
cell.value = header
|
||||||
|
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFE8F5E9' }
|
||||||
|
}
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center' }
|
||||||
|
})
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
// 设置列宽
|
||||||
|
worksheet.getColumn(1).width = 8 // 序号
|
||||||
|
worksheet.getColumn(2).width = 20 // 时间
|
||||||
|
worksheet.getColumn(3).width = 18 // 发送者昵称
|
||||||
|
worksheet.getColumn(4).width = 25 // 发送者微信ID
|
||||||
|
worksheet.getColumn(5).width = 18 // 发送者备注
|
||||||
|
worksheet.getColumn(6).width = 15 // 发送者身份
|
||||||
|
worksheet.getColumn(7).width = 12 // 消息类型
|
||||||
|
worksheet.getColumn(8).width = 50 // 内容
|
||||||
|
|
||||||
|
// 填充数据
|
||||||
|
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
|
// 媒体导出设置
|
||||||
|
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
|
||||||
|
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
|
||||||
|
|
||||||
|
// 媒体导出缓存
|
||||||
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
|
const msg = sortedMessages[i]
|
||||||
|
|
||||||
|
// 导出媒体文件
|
||||||
|
let mediaItem: MediaExportItem | null = null
|
||||||
|
if (exportMediaEnabled) {
|
||||||
|
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||||
|
if (mediaCache.has(mediaKey)) {
|
||||||
|
mediaItem = mediaCache.get(mediaKey) || null
|
||||||
|
} else {
|
||||||
|
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
|
||||||
|
exportImages: options.exportImages,
|
||||||
|
exportVoices: options.exportVoices,
|
||||||
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportVoiceAsText: options.exportVoiceAsText
|
||||||
|
})
|
||||||
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定发送者信息
|
||||||
|
let senderRole: string
|
||||||
|
let senderWxid: string
|
||||||
|
let senderNickname: string
|
||||||
|
let senderRemark: string = ''
|
||||||
|
|
||||||
|
if (msg.isSend) {
|
||||||
|
// 我发送的消息
|
||||||
|
senderRole = '我'
|
||||||
|
senderWxid = cleanedMyWxid
|
||||||
|
senderNickname = myInfo.displayName || cleanedMyWxid
|
||||||
|
senderRemark = ''
|
||||||
|
} else if (isGroup && msg.senderUsername) {
|
||||||
|
// 群消息
|
||||||
|
senderWxid = msg.senderUsername
|
||||||
|
|
||||||
|
// 用 getContact 获取联系人详情,分别取昵称和备注
|
||||||
|
const contactDetail = await wcdbService.getContact(msg.senderUsername)
|
||||||
|
if (contactDetail.success && contactDetail.contact) {
|
||||||
|
// nickName 才是真正的昵称
|
||||||
|
senderNickname = contactDetail.contact.nickName || msg.senderUsername
|
||||||
|
senderRemark = contactDetail.contact.remark || ''
|
||||||
|
// 身份:有备注显示备注,没有显示昵称
|
||||||
|
senderRole = senderRemark || senderNickname
|
||||||
|
} else {
|
||||||
|
senderNickname = msg.senderUsername
|
||||||
|
senderRemark = ''
|
||||||
|
senderRole = msg.senderUsername
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 单聊对方消息 - 用 getContact 获取联系人详情
|
||||||
|
senderWxid = sessionId
|
||||||
|
const contactDetail = await wcdbService.getContact(sessionId)
|
||||||
|
if (contactDetail.success && contactDetail.contact) {
|
||||||
|
senderNickname = contactDetail.contact.nickName || sessionId
|
||||||
|
senderRemark = contactDetail.contact.remark || ''
|
||||||
|
senderRole = senderRemark || senderNickname
|
||||||
|
} else {
|
||||||
|
senderNickname = sessionInfo.displayName || sessionId
|
||||||
|
senderRemark = ''
|
||||||
|
senderRole = senderNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = worksheet.getRow(currentRow)
|
||||||
|
row.height = 24
|
||||||
|
|
||||||
|
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
|
||||||
|
const contentValue = mediaItem
|
||||||
|
? mediaItem.relativePath
|
||||||
|
: (this.parseMessageContent(msg.content, msg.localType) || '')
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.getCell(currentRow, 1).value = i + 1
|
||||||
|
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||||
|
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||||
|
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||||
|
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||||
|
worksheet.getCell(currentRow, 6).value = senderRole
|
||||||
|
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||||
|
worksheet.getCell(currentRow, 8).value = contentValue
|
||||||
|
|
||||||
|
// 设置每个单元格的样式
|
||||||
|
for (let col = 1; col <= 8; col++) {
|
||||||
|
const cell = worksheet.getCell(currentRow, col)
|
||||||
|
cell.font = { name: 'Calibri', size: 11 }
|
||||||
|
cell.alignment = { vertical: 'middle', wrapText: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRow++
|
||||||
|
|
||||||
|
// 每处理 100 条消息报告一次进度
|
||||||
|
if ((i + 1) % 100 === 0) {
|
||||||
|
const progress = 30 + Math.floor((i + 1) / sortedMessages.length * 50)
|
||||||
|
onProgress?.({
|
||||||
|
current: progress,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'exporting'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 90,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'writing'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
current: 100,
|
||||||
|
total: 100,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
phase: 'complete'
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
// 处理文件被占用的错误
|
||||||
|
if (e instanceof Error) {
|
||||||
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
|
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -928,15 +1643,25 @@ class ExportService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
|
||||||
|
// 为每个会话创建单独的文件夹
|
||||||
|
const sessionDir = path.join(outputDir, safeName)
|
||||||
|
if (!fs.existsSync(sessionDir)) {
|
||||||
|
fs.mkdirSync(sessionDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
let ext = '.json'
|
let ext = '.json'
|
||||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||||
const outputPath = path.join(outputDir, `${safeName}${ext}`)
|
else if (options.format === 'excel') ext = '.xlsx'
|
||||||
|
const outputPath = path.join(sessionDir, `${safeName}${ext}`)
|
||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
if (options.format === 'json') {
|
if (options.format === 'json') {
|
||||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
|
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
|
||||||
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
|
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
|
||||||
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
|
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
|
||||||
|
} else if (options.format === 'excel') {
|
||||||
|
result = await this.exportSessionToExcel(sessionId, outputPath, options)
|
||||||
} else {
|
} else {
|
||||||
result = { success: false, error: `不支持的格式: ${options.format}` }
|
result = { success: false, error: `不支持的格式: ${options.format}` }
|
||||||
}
|
}
|
||||||
@@ -964,3 +1689,4 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportService = new ExportService()
|
export const exportService = new ExportService()
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,44 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||||
import { writeFile } from 'fs/promises'
|
import { writeFile, rm, readdir } from 'fs/promises'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { Worker } from 'worker_threads'
|
import { Worker } from 'worker_threads'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
// 获取 ffmpeg-static 的路径
|
||||||
|
function getStaticFfmpegPath(): string | null {
|
||||||
|
try {
|
||||||
|
// 方法1: 直接 require ffmpeg-static
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
|
||||||
|
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||||
|
return ffmpegStatic
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: 手动构建路径(开发环境)
|
||||||
|
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
|
if (existsSync(devPath)) {
|
||||||
|
return devPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3: 打包后的路径
|
||||||
|
if (app.isPackaged) {
|
||||||
|
const resourcesPath = process.resourcesPath
|
||||||
|
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||||
|
if (existsSync(packedPath)) {
|
||||||
|
return packedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type DecryptResult = {
|
type DecryptResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
localPath?: string
|
localPath?: string
|
||||||
@@ -83,7 +115,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const cached = this.resolvedCache.get(key)
|
const cached = this.resolvedCache.get(key)
|
||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
|
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const dataUrl = this.fileToDataUrl(cached)
|
||||||
const isThumb = this.isThumbnailPath(cached)
|
const isThumb = this.isThumbnailPath(cached)
|
||||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||||
@@ -103,7 +134,6 @@ export class ImageDecryptService {
|
|||||||
for (const key of cacheKeys) {
|
for (const key of cacheKeys) {
|
||||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
|
|
||||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||||
const dataUrl = this.fileToDataUrl(existing)
|
const dataUrl = this.fileToDataUrl(existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
@@ -238,20 +268,39 @@ export class ImageDecryptService {
|
|||||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||||
|
|
||||||
const ext = this.detectImageExtension(decrypted) || '.jpg'
|
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||||
|
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||||
|
decrypted = wxgfResult.data
|
||||||
|
|
||||||
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
|
let ext = this.detectImageExtension(decrypted)
|
||||||
|
|
||||||
|
// 如果是 wxgf 格式且没检测到扩展名
|
||||||
|
if (wxgfResult.isWxgf && !ext) {
|
||||||
|
ext = '.hevc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalExt = ext || '.jpg'
|
||||||
|
|
||||||
|
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||||
|
|
||||||
|
// 对于 hevc 格式,返回错误提示
|
||||||
|
if (finalExt === '.hevc') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||||
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||||
}
|
}
|
||||||
const dataUrl = this.bufferToDataUrl(decrypted, ext)
|
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb }
|
||||||
@@ -904,6 +953,18 @@ export class ImageDecryptService {
|
|||||||
extensions: string[],
|
extensions: string[],
|
||||||
preferHd: boolean
|
preferHd: boolean
|
||||||
): string | null {
|
): string | null {
|
||||||
|
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||||
|
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||||
|
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||||
|
try {
|
||||||
|
if (existsSync(hevcThumb)) {
|
||||||
|
require('fs').unlinkSync(hevcThumb)
|
||||||
|
}
|
||||||
|
if (existsSync(hevcHd)) {
|
||||||
|
require('fs').unlinkSync(hevcHd)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
if (preferHd) {
|
if (preferHd) {
|
||||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||||
@@ -1406,6 +1467,159 @@ export class ImageDecryptService {
|
|||||||
return mostCommonKey
|
return mostCommonKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解包 wxgf 格式
|
||||||
|
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||||
|
*/
|
||||||
|
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
|
||||||
|
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
|
||||||
|
if (buffer.length < 20 ||
|
||||||
|
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||||
|
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||||
|
return { data: buffer, isWxgf: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先尝试搜索内嵌的传统图片签名
|
||||||
|
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
|
||||||
|
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||||
|
return { data: buffer.subarray(i), isWxgf: false }
|
||||||
|
}
|
||||||
|
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||||
|
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||||
|
return { data: buffer.subarray(i), isWxgf: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 HEVC NALU 裸流
|
||||||
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
|
if (!hevcData || hevcData.length < 100) {
|
||||||
|
return { data: buffer, isWxgf: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试用 ffmpeg 转换
|
||||||
|
try {
|
||||||
|
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||||
|
if (jpgData && jpgData.length > 0) {
|
||||||
|
return { data: jpgData, isWxgf: false }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ffmpeg 转换失败
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: hevcData, isWxgf: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||||
|
*/
|
||||||
|
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||||
|
const nalUnits: Buffer[] = []
|
||||||
|
let i = 4
|
||||||
|
|
||||||
|
while (i < buffer.length - 4) {
|
||||||
|
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||||
|
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||||
|
let nalStart = i
|
||||||
|
let nalEnd = buffer.length
|
||||||
|
|
||||||
|
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||||
|
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||||
|
if (buffer[j + 2] === 0x01 ||
|
||||||
|
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||||
|
nalEnd = j
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||||
|
if (nalUnit.length > 3) {
|
||||||
|
nalUnits.push(nalUnit)
|
||||||
|
}
|
||||||
|
i = nalEnd
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nalUnits.length === 0) {
|
||||||
|
for (let j = 4; j < buffer.length - 4; j++) {
|
||||||
|
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||||
|
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||||
|
return buffer.subarray(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat(nalUnits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ffmpeg 可执行文件路径
|
||||||
|
*/
|
||||||
|
private getFfmpegPath(): string {
|
||||||
|
const staticPath = getStaticFfmpegPath()
|
||||||
|
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
|
||||||
|
|
||||||
|
if (staticPath) {
|
||||||
|
return staticPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到系统 ffmpeg
|
||||||
|
return 'ffmpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
|
*/
|
||||||
|
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
|
const ffmpeg = this.getFfmpegPath()
|
||||||
|
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const { spawn } = require('child_process')
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
|
const proc = spawn(ffmpeg, [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-f', 'hevc',
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-vframes', '1',
|
||||||
|
'-q:v', '3',
|
||||||
|
'-f', 'mjpeg',
|
||||||
|
'pipe:1'
|
||||||
|
], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
|
proc.on('close', (code: number) => {
|
||||||
|
if (code === 0 && chunks.length > 0) {
|
||||||
|
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
||||||
|
resolve(Buffer.concat(chunks))
|
||||||
|
} else {
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString()
|
||||||
|
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('error', (err: Error) => {
|
||||||
|
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.stdin.write(hevcData)
|
||||||
|
proc.stdin.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 保留原有的解密到文件方法(用于兼容)
|
// 保留原有的解密到文件方法(用于兼容)
|
||||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||||
const version = this.getDatVersion(inputPath)
|
const version = this.getDatVersion(inputPath)
|
||||||
@@ -1430,6 +1644,71 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
this.resolvedCache.clear()
|
||||||
|
this.hardlinkCache.clear()
|
||||||
|
this.pending.clear()
|
||||||
|
this.updateFlags.clear()
|
||||||
|
this.cacheIndexed = false
|
||||||
|
this.cacheIndexing = null
|
||||||
|
|
||||||
|
const configured = this.configService.get('cachePath')
|
||||||
|
const root = configured
|
||||||
|
? join(configured, 'Images')
|
||||||
|
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(root)) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
const monthPattern = /^\d{4}-\d{2}$/
|
||||||
|
const clearFilesInDir = async (dirPath: string): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dirPath, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await clearFilesInDir(fullPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rm(fullPath, { force: true })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const traverse = async (dirPath: string): Promise<void> => {
|
||||||
|
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||||
|
try {
|
||||||
|
entries = await readdir(dirPath, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dirPath, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (monthPattern.test(entry.name)) {
|
||||||
|
await clearFilesInDir(fullPath)
|
||||||
|
} else {
|
||||||
|
await traverse(fullPath)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rm(fullPath, { force: true })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await traverse(root)
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageDecryptService = new ImageDecryptService()
|
export const imageDecryptService = new ImageDecryptService()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
@@ -15,7 +15,7 @@ export class MessageCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('userData'), 'WeFlowCache')
|
: join(app.getPath('documents'), 'WeFlow')
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
@@ -65,4 +65,13 @@ export class MessageCacheService {
|
|||||||
console.error('MessageCacheService: 保存缓存失败', error)
|
console.error('MessageCacheService: 保存缓存失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache = {}
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
374
electron/services/voiceTranscribeService.ts
Normal file
374
electron/services/voiceTranscribeService.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import * as https from 'https'
|
||||||
|
import * as http from 'http'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
// Sherpa-onnx 类型定义
|
||||||
|
type OfflineRecognizer = any
|
||||||
|
type OfflineStream = any
|
||||||
|
|
||||||
|
type ModelInfo = {
|
||||||
|
name: string
|
||||||
|
files: {
|
||||||
|
model: string
|
||||||
|
tokens: string
|
||||||
|
}
|
||||||
|
sizeBytes: number
|
||||||
|
sizeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadProgress = {
|
||||||
|
modelName: string
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes?: number
|
||||||
|
percent?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SENSEVOICE_MODEL: ModelInfo = {
|
||||||
|
name: 'SenseVoiceSmall',
|
||||||
|
files: {
|
||||||
|
model: 'model.int8.onnx',
|
||||||
|
tokens: 'tokens.txt'
|
||||||
|
},
|
||||||
|
sizeBytes: 245_000_000,
|
||||||
|
sizeLabel: '245 MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_DOWNLOAD_URLS = {
|
||||||
|
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
|
||||||
|
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VoiceTranscribeService {
|
||||||
|
private configService = new ConfigService()
|
||||||
|
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||||
|
private recognizer: OfflineRecognizer | null = null
|
||||||
|
private isInitializing = false
|
||||||
|
|
||||||
|
private resolveModelDir(): string {
|
||||||
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
|
if (configured) return configured
|
||||||
|
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveModelPath(fileName: string): string {
|
||||||
|
return join(this.resolveModelDir(), fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模型状态
|
||||||
|
*/
|
||||||
|
async getModelStatus(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
exists?: boolean
|
||||||
|
modelPath?: string
|
||||||
|
tokensPath?: string
|
||||||
|
sizeBytes?: number
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
const modelExists = existsSync(modelPath)
|
||||||
|
const tokensExists = existsSync(tokensPath)
|
||||||
|
const exists = modelExists && tokensExists
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
return { success: true, exists: false, modelPath, tokensPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelSize = statSync(modelPath).size
|
||||||
|
const tokensSize = statSync(tokensPath).size
|
||||||
|
const totalSize = modelSize + tokensSize
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
exists: true,
|
||||||
|
modelPath,
|
||||||
|
tokensPath,
|
||||||
|
sizeBytes: totalSize
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载模型文件
|
||||||
|
*/
|
||||||
|
async downloadModel(
|
||||||
|
onProgress?: (progress: DownloadProgress) => void
|
||||||
|
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
|
||||||
|
const cacheKey = 'sensevoice'
|
||||||
|
const pending = this.downloadTasks.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
try {
|
||||||
|
const modelDir = this.resolveModelDir()
|
||||||
|
if (!existsSync(modelDir)) {
|
||||||
|
mkdirSync(modelDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
|
||||||
|
// 初始进度
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下载模型文件 (40%)
|
||||||
|
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||||
|
await this.downloadToFile(
|
||||||
|
MODEL_DOWNLOAD_URLS.model,
|
||||||
|
modelPath,
|
||||||
|
'model',
|
||||||
|
(downloaded, total) => {
|
||||||
|
const percent = total ? (downloaded / total) * 40 : undefined
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: downloaded,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 下载 tokens 文件 (30%)
|
||||||
|
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||||
|
await this.downloadToFile(
|
||||||
|
MODEL_DOWNLOAD_URLS.tokens,
|
||||||
|
tokensPath,
|
||||||
|
'tokens',
|
||||||
|
(downloaded, total) => {
|
||||||
|
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||||
|
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
||||||
|
onProgress?.({
|
||||||
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
|
downloadedBytes: modelSize + downloaded,
|
||||||
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
|
percent
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
console.info('[VoiceTranscribe] 模型下载完成')
|
||||||
|
|
||||||
|
console.info('[VoiceTranscribe] 所有文件下载完成')
|
||||||
|
return { success: true, modelPath, tokensPath }
|
||||||
|
} catch (error) {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
try {
|
||||||
|
if (existsSync(modelPath)) unlinkSync(modelPath)
|
||||||
|
if (existsSync(tokensPath)) unlinkSync(tokensPath)
|
||||||
|
} catch { }
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
} finally {
|
||||||
|
this.downloadTasks.delete(cacheKey)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.downloadTasks.set(cacheKey, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
||||||
|
*/
|
||||||
|
async transcribeWavBuffer(
|
||||||
|
wavData: Buffer,
|
||||||
|
onPartial?: (text: string) => void,
|
||||||
|
languages?: string[]
|
||||||
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
|
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||||
|
|
||||||
|
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
|
||||||
|
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置的语言列表,如果没有传入则从配置读取
|
||||||
|
let supportedLanguages = languages
|
||||||
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
|
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||||
|
// 如果配置中也没有或为空,使用默认值
|
||||||
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
|
supportedLanguages = ['zh', 'yue']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Worker } = require('worker_threads')
|
||||||
|
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
||||||
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
|
const worker = new Worker(workerPath, {
|
||||||
|
workerData: {
|
||||||
|
modelPath,
|
||||||
|
tokensPath,
|
||||||
|
wavData,
|
||||||
|
sampleRate: 16000,
|
||||||
|
languages: supportedLanguages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let finalTranscript = ''
|
||||||
|
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
if (msg.type === 'partial') {
|
||||||
|
onPartial?.(msg.text)
|
||||||
|
} else if (msg.type === 'final') {
|
||||||
|
finalTranscript = msg.text
|
||||||
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
|
worker.terminate()
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
resolve({ success: false, error: msg.error })
|
||||||
|
worker.terminate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('error', (err: Error) => {
|
||||||
|
resolve({ success: false, error: String(err) })
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('exit', (code: number) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
||||||
|
resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ success: false, error: String(error) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
private downloadToFile(
|
||||||
|
url: string,
|
||||||
|
targetPath: string,
|
||||||
|
fileName: string,
|
||||||
|
onProgress?: (downloaded: number, total?: number) => void,
|
||||||
|
remainingRedirects = 5
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000 // 30秒连接超时
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = protocol.get(url, options, (response) => {
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
||||||
|
|
||||||
|
// 处理重定向
|
||||||
|
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||||
|
if (remainingRedirects <= 0) {
|
||||||
|
reject(new Error('重定向次数过多'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
||||||
|
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||||
|
let downloadedBytes = 0
|
||||||
|
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
|
||||||
|
|
||||||
|
const writer = createWriteStream(targetPath)
|
||||||
|
|
||||||
|
// 设置数据接收超时(60秒没有数据则超时)
|
||||||
|
let lastDataTime = Date.now()
|
||||||
|
const dataTimeout = setInterval(() => {
|
||||||
|
if (Date.now() - lastDataTime > 60000) {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
response.destroy()
|
||||||
|
writer.close()
|
||||||
|
reject(new Error('下载超时:60秒内未收到数据'))
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
lastDataTime = Date.now()
|
||||||
|
downloadedBytes += chunk.length
|
||||||
|
onProgress?.(downloadedBytes, totalBytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('error', (error) => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
try { writer.close() } catch { }
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
writer.on('error', (error) => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
try { writer.close() } catch { }
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
writer.on('finish', () => {
|
||||||
|
clearInterval(dataTimeout)
|
||||||
|
writer.close()
|
||||||
|
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.pipe(writer)
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy()
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
||||||
|
reject(new Error('连接超时'))
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (this.recognizer) {
|
||||||
|
try {
|
||||||
|
// sherpa-onnx 的 recognizer 可能需要手动释放
|
||||||
|
this.recognizer = null
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageById: any = null
|
private wcdbGetMessageById: any = null
|
||||||
private wcdbGetEmoticonCdnUrl: any = null
|
private wcdbGetEmoticonCdnUrl: any = null
|
||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
|
private wcdbGetVoiceData: any = null
|
||||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -108,12 +109,13 @@ export class WcdbCore {
|
|||||||
|
|
||||||
private writeLog(message: string, force = false): void {
|
private writeLog(message: string, force = false): void {
|
||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
|
console.log(`[WCDB] ${line}`)
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||||
const dir = join(base, 'logs')
|
const dir = join(base, 'logs')
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
const line = `[${new Date().toISOString()}] ${message}\n`
|
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
|
||||||
appendFileSync(join(dir, 'wcdb.log'), line, { encoding: 'utf8' })
|
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +347,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetDbStatus = null
|
this.wcdbGetDbStatus = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_voice_data(wcdb_handle handle, const char* session_id, int32_t create_time, int32_t local_id, int64_t svr_id, const char* candidates_json, char** out_hex)
|
||||||
|
try {
|
||||||
|
this.wcdbGetVoiceData = this.lib.func('int32 wcdb_get_voice_data(int64 handle, const char* sessionId, int32 createTime, int32 localId, int64 svrId, const char* candidatesJson, _Out_ void** outHex)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetVoiceData = null
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -1295,9 +1304,7 @@ export class WcdbCore {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
} async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
||||||
|
|
||||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: any; error?: string }> {
|
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
@@ -1313,5 +1320,21 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||||
|
}
|
||||||
|
const hex = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (hex === null) return { success: false, error: '解析语音数据失败' }
|
||||||
|
return { success: true, hex: hex || undefined }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ export class WcdbService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('error', (err) => {
|
this.worker.on('error', (err) => {
|
||||||
console.error('WCDB Worker 错误:', err)
|
// Worker error
|
||||||
})
|
})
|
||||||
|
|
||||||
this.worker.on('exit', (code) => {
|
this.worker.on('exit', (code) => {
|
||||||
if (code !== 0) console.error(`WCDB Worker 异常退出,退出码: ${code}`)
|
if (code !== 0) {
|
||||||
|
// Worker exited with error
|
||||||
|
}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ export class WcdbService {
|
|||||||
this.setLogEnabled(this.logEnabled)
|
this.setLogEnabled(this.logEnabled)
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('创建 WCDB Worker 失败:', e)
|
// Failed to create worker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ export class WcdbService {
|
|||||||
setPaths(resourcesPath: string, userDataPath: string): void {
|
setPaths(resourcesPath: string, userDataPath: string): void {
|
||||||
this.resourcesPath = resourcesPath
|
this.resourcesPath = resourcesPath
|
||||||
this.userDataPath = userDataPath
|
this.userDataPath = userDataPath
|
||||||
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(console.error)
|
this.callWorker('setPaths', { resourcesPath, userDataPath }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,7 +107,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setLogEnabled(enabled: boolean): void {
|
setLogEnabled(enabled: boolean): void {
|
||||||
this.logEnabled = enabled
|
this.logEnabled = enabled
|
||||||
this.callWorker('setLogEnabled', { enabled }).catch(console.error)
|
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,6 +343,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取语音数据
|
||||||
|
*/
|
||||||
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
166
electron/transcribeWorker.ts
Normal file
166
electron/transcribeWorker.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
|
||||||
|
interface WorkerParams {
|
||||||
|
modelPath: string
|
||||||
|
tokensPath: string
|
||||||
|
wavData: Buffer
|
||||||
|
sampleRate: number
|
||||||
|
languages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语言标记映射
|
||||||
|
const LANGUAGE_TAGS: Record<string, string> = {
|
||||||
|
'zh': '<|zh|>',
|
||||||
|
'en': '<|en|>',
|
||||||
|
'ja': '<|ja|>',
|
||||||
|
'ko': '<|ko|>',
|
||||||
|
'yue': '<|yue|>' // 粤语
|
||||||
|
}
|
||||||
|
|
||||||
|
// 技术标签(识别语言、语速、ITN等),需要从最终文本中移除
|
||||||
|
const TECH_TAGS = [
|
||||||
|
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
|
||||||
|
'<|nospeech|>', '<|speech|>',
|
||||||
|
'<|itn|>', '<|wo_itn|>',
|
||||||
|
'<|NORMAL|>'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 情感与事件标签映射,转换为直观的 Emoji
|
||||||
|
const RICH_TAG_MAP: Record<string, string> = {
|
||||||
|
'<|HAPPY|>': '😊',
|
||||||
|
'<|SAD|>': '😔',
|
||||||
|
'<|ANGRY|>': '😠',
|
||||||
|
'<|NEUTRAL|>': '', // 中性情感不特别标记
|
||||||
|
'<|FEARFUL|>': '😨',
|
||||||
|
'<|DISGUSTED|>': '🤢',
|
||||||
|
'<|SURPRISED|>': '😮',
|
||||||
|
'<|BGM|>': '🎵',
|
||||||
|
'<|Applause|>': '👏',
|
||||||
|
'<|Laughter|>': '😂',
|
||||||
|
'<|Cry|>': '😭',
|
||||||
|
'<|Cough|>': ' (咳嗽) ',
|
||||||
|
'<|Sneeze|>': ' (喷嚏) ',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
|
||||||
|
*/
|
||||||
|
function richTranscribePostProcess(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
let processed = text
|
||||||
|
|
||||||
|
// 1. 转换情感和事件标签
|
||||||
|
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
|
||||||
|
// 使用正则全局替换,不区分大小写以防不同版本差异
|
||||||
|
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||||
|
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 移除所有剩余的技术标签
|
||||||
|
for (const tag of TECH_TAGS) {
|
||||||
|
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||||
|
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清理多余空格并返回
|
||||||
|
return processed.replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查识别结果是否在允许的语言列表中
|
||||||
|
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
||||||
|
if (!result || !result.lang) {
|
||||||
|
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
|
||||||
|
if (!allowedLanguages || allowedLanguages.length === 0) {
|
||||||
|
allowedLanguages = ['zh', 'yue']
|
||||||
|
}
|
||||||
|
|
||||||
|
const langTag = result.lang
|
||||||
|
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
|
||||||
|
|
||||||
|
// 检查是否在允许的语言列表中
|
||||||
|
for (const lang of allowedLanguages) {
|
||||||
|
if (LANGUAGE_TAGS[lang] === langTag) {
|
||||||
|
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!parentPort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||||
|
let sherpa: any;
|
||||||
|
try {
|
||||||
|
sherpa = require('sherpa-onnx-node');
|
||||||
|
} catch (requireError) {
|
||||||
|
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
||||||
|
const wavData = Buffer.from(rawWavData);
|
||||||
|
// 确保有有效的语言列表,默认只允许中文
|
||||||
|
let allowedLanguages = languages || ['zh']
|
||||||
|
if (allowedLanguages.length === 0) {
|
||||||
|
allowedLanguages = ['zh']
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
|
||||||
|
|
||||||
|
// 1. 初始化识别器 (SenseVoiceSmall)
|
||||||
|
const recognizerConfig = {
|
||||||
|
modelConfig: {
|
||||||
|
senseVoice: {
|
||||||
|
model: modelPath,
|
||||||
|
useInverseTextNormalization: 1
|
||||||
|
},
|
||||||
|
tokens: tokensPath,
|
||||||
|
numThreads: 2,
|
||||||
|
debug: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
|
||||||
|
|
||||||
|
// 2. 处理音频数据 (全量识别)
|
||||||
|
const pcmData = wavData.slice(44)
|
||||||
|
const samples = new Float32Array(pcmData.length / 2)
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = recognizer.createStream()
|
||||||
|
stream.acceptWaveform({ sampleRate, samples })
|
||||||
|
recognizer.decode(stream)
|
||||||
|
const result = recognizer.getResult(stream)
|
||||||
|
|
||||||
|
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
|
||||||
|
|
||||||
|
// 3. 检查语言是否在白名单中
|
||||||
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
|
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
|
||||||
|
parentPort.postMessage({ type: 'final', text: processedText })
|
||||||
|
} else {
|
||||||
|
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
|
||||||
|
parentPort.postMessage({ type: 'final', text: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'sherpa-onnx-node' {
|
||||||
|
const content: any;
|
||||||
|
export = content;
|
||||||
|
}
|
||||||
22
electron/types/whisper-node.d.ts
vendored
Normal file
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[]>
|
||||||
|
}
|
||||||
@@ -110,6 +110,12 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'getVoiceData':
|
||||||
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
|
}
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
609
package-lock.json
generated
609
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.4",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.4",
|
"version": "1.2.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -344,6 +348,21 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@derhuerst/http-basic": {
|
||||||
|
"version": "8.2.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||||
|
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"caseless": "^0.12.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"http-response-object": "^3.0.1",
|
||||||
|
"parse-cache-control": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -1124,6 +1143,47 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fast-csv/format": {
|
||||||
|
"version": "4.3.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz",
|
||||||
|
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^14.0.1",
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.isfunction": "^3.0.9",
|
||||||
|
"lodash.isnil": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/format/node_modules/@types/node": {
|
||||||
|
"version": "14.18.63",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
|
||||||
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/parse": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^14.0.1",
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.groupby": "^4.6.0",
|
||||||
|
"lodash.isfunction": "^3.0.9",
|
||||||
|
"lodash.isnil": "^4.0.0",
|
||||||
|
"lodash.isundefined": "^3.0.1",
|
||||||
|
"lodash.uniq": "^4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fast-csv/parse/node_modules/@types/node": {
|
||||||
|
"version": "14.18.63",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
|
||||||
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@gar/promisify": {
|
"node_modules/@gar/promisify": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||||
@@ -3564,9 +3624,7 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz",
|
||||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^2.1.0",
|
"archiver-utils": "^2.1.0",
|
||||||
"async": "^3.2.4",
|
"async": "^3.2.4",
|
||||||
@@ -3584,9 +3642,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
||||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.1.4",
|
"glob": "^7.1.4",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
@@ -3607,9 +3663,7 @@
|
|||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -3624,17 +3678,13 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -3686,7 +3736,6 @@
|
|||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/async-exit-hook": {
|
"node_modules/async-exit-hook": {
|
||||||
@@ -3730,7 +3779,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": {
|
||||||
@@ -3786,6 +3834,28 @@
|
|||||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/binary": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffers": "~0.1.1",
|
||||||
|
"chainsaw": "~0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bindings": {
|
"node_modules/bindings": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
|
||||||
@@ -3836,7 +3906,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",
|
||||||
@@ -3919,7 +3988,6 @@
|
|||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
@@ -3929,9 +3997,25 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-indexof-polyfill": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffers": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/builder-util": {
|
"node_modules/builder-util": {
|
||||||
"version": "25.1.7",
|
"version": "25.1.7",
|
||||||
"resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz",
|
"resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz",
|
||||||
@@ -4188,6 +4272,24 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/caseless": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/chainsaw": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
|
||||||
|
"license": "MIT/X11",
|
||||||
|
"dependencies": {
|
||||||
|
"traverse": ">=0.3.0 <0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -4413,9 +4515,7 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-crc32": "^0.2.13",
|
"buffer-crc32": "^0.2.13",
|
||||||
"crc32-stream": "^4.0.2",
|
"crc32-stream": "^4.0.2",
|
||||||
@@ -4430,9 +4530,23 @@
|
|||||||
"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": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/conf": {
|
"node_modules/conf": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz",
|
||||||
@@ -4596,9 +4710,7 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"crc32": "bin/crc32.njs"
|
"crc32": "bin/crc32.njs"
|
||||||
},
|
},
|
||||||
@@ -4610,9 +4722,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz",
|
||||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crc-32": "^1.2.0",
|
"crc-32": "^1.2.0",
|
||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
@@ -4652,6 +4762,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debounce-fn": {
|
"node_modules/debounce-fn": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
||||||
@@ -4981,6 +5097,45 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexer2": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/duplexer2/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/duplexer2/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/duplexer2/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
@@ -5355,7 +5510,6 @@
|
|||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -5491,6 +5645,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exceljs": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver": "^5.0.0",
|
||||||
|
"dayjs": "^1.8.34",
|
||||||
|
"fast-csv": "^4.3.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"readable-stream": "^3.6.0",
|
||||||
|
"saxes": "^5.0.1",
|
||||||
|
"tmp": "^0.2.0",
|
||||||
|
"unzipper": "^0.10.11",
|
||||||
|
"uuid": "^8.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expand-template": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -5539,6 +5713,19 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-csv": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fast-csv/format": "4.3.5",
|
||||||
|
"@fast-csv/parse": "4.3.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -5578,6 +5765,47 @@
|
|||||||
"pend": "~1.2.0"
|
"pend": "~1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ffmpeg-static": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"@derhuerst/http-basic": "^8.2.0",
|
||||||
|
"env-paths": "^2.2.0",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"progress": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ffmpeg-static/node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -5716,7 +5944,6 @@
|
|||||||
"version": "1.0.0",
|
"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": {
|
||||||
@@ -5734,6 +5961,47 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fstream": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.2",
|
||||||
|
"inherits": "~2.0.0",
|
||||||
|
"mkdirp": ">=0.5 0",
|
||||||
|
"rimraf": "2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fstream/node_modules/mkdirp": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fstream/node_modules/rimraf": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||||
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^7.1.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"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",
|
||||||
@@ -5857,7 +6125,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",
|
||||||
@@ -5878,7 +6145,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,6 +6375,21 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-response-object": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^10.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-response-object/node_modules/@types/node": {
|
||||||
|
"version": "10.17.60",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz",
|
||||||
|
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/http2-wrapper": {
|
"node_modules/http2-wrapper": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||||
@@ -6243,7 +6524,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
|
"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",
|
||||||
@@ -6582,9 +6862,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readable-stream": "^2.0.5"
|
"readable-stream": "^2.0.5"
|
||||||
},
|
},
|
||||||
@@ -6596,9 +6874,7 @@
|
|||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"core-util-is": "~1.0.0",
|
||||||
"inherits": "~2.0.3",
|
"inherits": "~2.0.3",
|
||||||
@@ -6613,17 +6889,13 @@
|
|||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lazystream/node_modules/string_decoder": {
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
@@ -6637,6 +6909,12 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/listenercount": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
@@ -6648,17 +6926,13 @@
|
|||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.difference": {
|
"node_modules/lodash.difference": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.escaperegexp": {
|
"node_modules/lodash.escaperegexp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
@@ -6670,9 +6944,19 @@
|
|||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
},
|
||||||
"peer": true
|
"node_modules/lodash.groupby": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isequal": {
|
"node_modules/lodash.isequal": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@@ -6681,21 +6965,41 @@
|
|||||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isfunction": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnil": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isplainobject": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
},
|
||||||
"peer": true
|
"node_modules/lodash.isundefined": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.union": {
|
"node_modules/lodash.union": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
},
|
||||||
"peer": true
|
"node_modules/lodash.uniq": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/log-symbols": {
|
"node_modules/log-symbols": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -7257,9 +7561,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7406,11 +7708,15 @@
|
|||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-cache-control": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"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"
|
||||||
@@ -7595,7 +7901,6 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
@@ -7770,9 +8075,7 @@
|
|||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimatch": "^5.1.0"
|
"minimatch": "^5.1.0"
|
||||||
}
|
}
|
||||||
@@ -7781,9 +8084,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
@@ -7792,9 +8093,7 @@
|
|||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz",
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -8042,6 +8341,18 @@
|
|||||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -8186,6 +8497,78 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sherpa-onnx-darwin-arm64": {
|
||||||
|
"version": "1.12.23",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.12.23.tgz",
|
||||||
|
"integrity": "sha512-zbjNUUH/IXhjRyRJ9mpcWVOGIVr31a/qXBPsfOYc7U8cgwcq33Vmj2OzoLYWQF6T+puqCAE4nMxFAxJvdZekhg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-linux-x64": {
|
||||||
|
"version": "1.12.23",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.12.23.tgz",
|
||||||
|
"integrity": "sha512-pUZIdDvPtyRXQDGo9R9MIBf2AFUzfgcGmutoulsEdH3hpK6JteR7Z/5pfrZIIqe/O99djAjEHK4AlwLHC2jiZw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-node": {
|
||||||
|
"version": "1.12.23",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-node/-/sherpa-onnx-node-1.12.23.tgz",
|
||||||
|
"integrity": "sha512-09SRixVSjsajxeCV8Hy9R5J4IHPtw7vNgaIcEokdh/LpU7sY+e12z9uHHIMMMgNiInyGEH74wIwjLXms+W7qRA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"sherpa-onnx-darwin-arm64": "^1.12.23",
|
||||||
|
"sherpa-onnx-darwin-x64": "^1.12.23",
|
||||||
|
"sherpa-onnx-linux-arm64": "^1.12.23",
|
||||||
|
"sherpa-onnx-linux-x64": "^1.12.23",
|
||||||
|
"sherpa-onnx-win-ia32": "^1.12.23",
|
||||||
|
"sherpa-onnx-win-x64": "^1.12.23"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-win-ia32": {
|
||||||
|
"version": "1.12.23",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||||
|
"integrity": "sha512-MyLsK7r6dd7paglyTgb8UHTXTEFqOzA91u6VDV64Lq8rDGuOFVYioxX7vlwmGe1A9o7VhuOPNaKcRjEPtVDhBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/sherpa-onnx-win-x64": {
|
||||||
|
"version": "1.12.23",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.12.23.tgz",
|
||||||
|
"integrity": "sha512-pdHEYMJiYy8+xzH2WkBVS4/hnRwqjY8FaWnjs0NBgQZnPmc/k4M+TAiauTOuFDNK4GPwFQnjwrCGx6jI9AOkOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"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",
|
||||||
@@ -8193,6 +8576,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/silk-wasm": {
|
||||||
|
"version": "3.7.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/silk-wasm/-/silk-wasm-3.7.1.tgz",
|
||||||
|
"integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/simple-concat": {
|
"node_modules/simple-concat": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
@@ -8731,7 +9123,6 @@
|
|||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
|
||||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.14"
|
"node": ">=14.14"
|
||||||
@@ -8761,6 +9152,15 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/traverse": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz",
|
||||||
|
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
|
||||||
|
"license": "MIT/X11",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/truncate-utf8-bytes": {
|
"node_modules/truncate-utf8-bytes": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||||
@@ -8801,6 +9201,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -8870,6 +9276,60 @@
|
|||||||
"node": ">= 4.0.0"
|
"node": ">= 4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unzipper": {
|
||||||
|
"version": "0.10.14",
|
||||||
|
"resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz",
|
||||||
|
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.17",
|
||||||
|
"binary": "~0.3.0",
|
||||||
|
"bluebird": "~3.4.1",
|
||||||
|
"buffer-indexof-polyfill": "~1.0.0",
|
||||||
|
"duplexer2": "~0.1.4",
|
||||||
|
"fstream": "^1.0.12",
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"listenercount": "~1.0.1",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "~1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipper/node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unzipper/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipper/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unzipper/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -8933,6 +9393,15 @@
|
|||||||
"base64-arraybuffer": "^1.0.2"
|
"base64-arraybuffer": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/verror": {
|
"node_modules/verror": {
|
||||||
"version": "1.10.1",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
|
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
|
||||||
@@ -9181,6 +9650,12 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||||
@@ -9255,9 +9730,7 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver-utils": "^3.0.4",
|
"archiver-utils": "^3.0.4",
|
||||||
"compress-commons": "^4.1.2",
|
"compress-commons": "^4.1.2",
|
||||||
@@ -9271,9 +9744,7 @@
|
|||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz",
|
||||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"glob": "^7.2.3",
|
"glob": "^7.2.3",
|
||||||
"graceful-fs": "^4.2.0",
|
"graceful-fs": "^4.2.0",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -97,6 +101,10 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist-electron/**/*"
|
"dist-electron/**/*"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/silk-wasm/**/*",
|
||||||
|
"node_modules/sherpa-onnx-node/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
resources/SDL2.dll
Normal file
BIN
resources/SDL2.dll
Normal file
Binary file not shown.
Binary file not shown.
73
src/components/AnimatedStreamingText.tsx
Normal file
73
src/components/AnimatedStreamingText.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { memo, useEffect, useState, useRef } from 'react'
|
||||||
|
|
||||||
|
interface AnimatedStreamingTextProps {
|
||||||
|
text: string
|
||||||
|
className?: string
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
|
||||||
|
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
|
||||||
|
const prevTextRef = useRef('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentText = (text || '').trim()
|
||||||
|
const prevText = prevTextRef.current
|
||||||
|
|
||||||
|
if (currentText === prevText) return
|
||||||
|
if (!currentText.startsWith(prevText) && prevText !== '') {
|
||||||
|
// 如果不是追加而是全新的文本(比如重新识别),则重置
|
||||||
|
setDisplayedSegments([currentText])
|
||||||
|
prevTextRef.current = currentText
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPart = currentText.slice(prevText.length)
|
||||||
|
if (newPart) {
|
||||||
|
// 将新部分作为单独的段加入,以触发动画
|
||||||
|
setDisplayedSegments(prev => [...prev, newPart])
|
||||||
|
}
|
||||||
|
prevTextRef.current = currentText
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
// 处理 loading 状态的显示
|
||||||
|
if (loading && !text) {
|
||||||
|
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{displayedSegments.map((segment, index) => (
|
||||||
|
<span key={index} className="fade-in-text">
|
||||||
|
{segment}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<style>{`
|
||||||
|
.fade-in-text {
|
||||||
|
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
opacity: 0;
|
||||||
|
display: inline-block;
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
@keyframes premiumFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px) scale(0.98);
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dot-flashing {
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
`}</style>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
|
||||||
262
src/components/VoiceTranscribeDialog.scss
Normal file
262
src/components/VoiceTranscribeDialog.scss
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
.voice-transcribe-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-transcribe-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-info {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.model-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
.download-icon {
|
||||||
|
.downloading-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
animation: bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
.complete-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/components/VoiceTranscribeDialog.tsx
Normal file
160
src/components/VoiceTranscribeDialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
|
import './VoiceTranscribeDialog.scss'
|
||||||
|
|
||||||
|
interface VoiceTranscribeDialogProps {
|
||||||
|
onClose: () => void
|
||||||
|
onDownloadComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||||
|
onClose,
|
||||||
|
onDownloadComplete
|
||||||
|
}) => {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||||
|
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||||
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 监听下载进度
|
||||||
|
if (!window.electronAPI?.whisper?.onDownloadProgress) {
|
||||||
|
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
|
||||||
|
if (payload.percent !== undefined) {
|
||||||
|
setDownloadProgress(payload.percent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeListener?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!window.electronAPI?.whisper?.downloadModel) {
|
||||||
|
setDownloadError('语音转文字功能不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloading(true)
|
||||||
|
setDownloadError(null)
|
||||||
|
setDownloadProgress(0)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.whisper.downloadModel()
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
setIsComplete(true)
|
||||||
|
setDownloadProgress(100)
|
||||||
|
|
||||||
|
// 延迟关闭弹窗并触发转写
|
||||||
|
setTimeout(() => {
|
||||||
|
onDownloadComplete()
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
setDownloadError(result?.error || '下载失败')
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDownloadError(String(error))
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (!isDownloading && !isComplete) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
|
||||||
|
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dialog-header">
|
||||||
|
<h3>语音转文字</h3>
|
||||||
|
{!isDownloading && !isComplete && (
|
||||||
|
<button className="close-button" onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog-content">
|
||||||
|
{!isDownloading && !isComplete && (
|
||||||
|
<>
|
||||||
|
<div className="info-section">
|
||||||
|
<AlertCircle size={48} className="info-icon" />
|
||||||
|
<p className="info-text">
|
||||||
|
首次使用语音转文字功能需要下载 AI 模型
|
||||||
|
</p>
|
||||||
|
<div className="model-info">
|
||||||
|
<div className="model-item">
|
||||||
|
<span className="label">模型名称:</span>
|
||||||
|
<span className="value">SenseVoiceSmall</span>
|
||||||
|
</div>
|
||||||
|
<div className="model-item">
|
||||||
|
<span className="label">文件大小:</span>
|
||||||
|
<span className="value">约 240 MB</span>
|
||||||
|
</div>
|
||||||
|
<div className="model-item">
|
||||||
|
<span className="label">支持语言:</span>
|
||||||
|
<span className="value">中文、粤语、英文、日文、韩文</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{downloadError && (
|
||||||
|
<div className="error-message">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{downloadError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={handleDownload}>
|
||||||
|
<Download size={16} />
|
||||||
|
<span>立即下载</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDownloading && !isComplete && (
|
||||||
|
<div className="download-section">
|
||||||
|
<div className="download-icon">
|
||||||
|
<Download size={48} className="downloading-icon" />
|
||||||
|
</div>
|
||||||
|
<p className="download-text">
|
||||||
|
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
|
||||||
|
</p>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${downloadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
|
||||||
|
{downloadProgress < 1 && (
|
||||||
|
<p className="download-hint">首次连接可能需要较长时间,请耐心等待</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isComplete && (
|
||||||
|
<div className="complete-section">
|
||||||
|
<CheckCircle size={48} className="complete-icon" />
|
||||||
|
<p className="complete-text">下载完成!正在转写语音...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1108,6 +1108,14 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
|
||||||
|
&.sent.voice {
|
||||||
|
.bubble-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-avatar {
|
.bubble-avatar {
|
||||||
@@ -1303,8 +1311,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble.sent .voice-message {
|
.voice-stack {
|
||||||
background: rgba(255, 255, 255, 0.18);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-play-btn {
|
.voice-play-btn {
|
||||||
@@ -1339,6 +1349,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-waveform {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-bar {
|
||||||
|
flex: 1;
|
||||||
|
width: 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&.played {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble.sent.voice .waveform-bar {
|
||||||
|
background: rgba(0, 0, 0, 0.1); // 基色改为透明黑
|
||||||
|
|
||||||
|
&.played {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-wave-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
height: 18px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 3px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.voice-message.playing .voice-wave span {
|
.voice-message.playing .voice-wave span {
|
||||||
animation: voicePulse 0.9s ease-in-out infinite;
|
animation: voicePulse 0.9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1445,24 @@
|
|||||||
color: #d9480f;
|
color: #d9480f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-transcript {
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-transcript.error {
|
||||||
|
color: #d9480f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes voicePulse {
|
@keyframes voicePulse {
|
||||||
0% {
|
0% {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -1848,3 +1920,32 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 语音转文字按钮样式 */
|
||||||
|
.voice-transcribe-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,22 @@ import { useChatStore } from '../stores/chatStore'
|
|||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { getEmojiPath } from 'wechat-emojis'
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
import { ImagePreview } from '../components/ImagePreview'
|
import { ImagePreview } from '../components/ImagePreview'
|
||||||
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
|
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './ChatPage.scss'
|
import './ChatPage.scss'
|
||||||
|
|
||||||
|
// 系统消息类型常量
|
||||||
|
const SYSTEM_MESSAGE_TYPES = [
|
||||||
|
10000, // 系统消息
|
||||||
|
266287972401, // 拍一拍
|
||||||
|
]
|
||||||
|
|
||||||
|
// 判断是否为系统消息
|
||||||
|
function isSystemMessage(localType: number): boolean {
|
||||||
|
return SYSTEM_MESSAGE_TYPES.includes(localType)
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
// 保留接口以备将来扩展
|
// 保留接口以备将来扩展
|
||||||
}
|
}
|
||||||
@@ -138,6 +152,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
|
|
||||||
// 联系人信息加载控制
|
// 联系人信息加载控制
|
||||||
const isEnrichingRef = useRef(false)
|
const isEnrichingRef = useRef(false)
|
||||||
@@ -971,6 +987,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRequireModelDownload = useCallback((sessionId: string, messageId: string) => {
|
||||||
|
setPendingVoiceTranscriptRequest({ sessionId, messageId })
|
||||||
|
setShowVoiceTranscribeDialog(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
||||||
{/* 左侧会话列表 */}
|
{/* 左侧会话列表 */}
|
||||||
@@ -1128,10 +1149,10 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||||
|
|
||||||
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
||||||
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
||||||
const isSent = msg.isSend === 1
|
const isSent = msg.isSend === 1
|
||||||
const isSystem = msg.localType === 10000
|
const isSystem = isSystemMessage(msg.localType)
|
||||||
|
|
||||||
// 系统消息居中显示
|
// 系统消息居中显示
|
||||||
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
||||||
@@ -1150,6 +1171,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
showTime={!showDateDivider && showTime}
|
showTime={!showDateDivider && showTime}
|
||||||
myAvatarUrl={myAvatarUrl}
|
myAvatarUrl={myAvatarUrl}
|
||||||
isGroupChat={isGroupChat(currentSession.username)}
|
isGroupChat={isGroupChat(currentSession.username)}
|
||||||
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1272,6 +1294,31 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 语音转文字模型下载弹窗 */}
|
||||||
|
{showVoiceTranscribeDialog && (
|
||||||
|
<VoiceTranscribeDialog
|
||||||
|
onClose={() => {
|
||||||
|
setShowVoiceTranscribeDialog(false)
|
||||||
|
setPendingVoiceTranscriptRequest(null)
|
||||||
|
}}
|
||||||
|
onDownloadComplete={async () => {
|
||||||
|
setShowVoiceTranscribeDialog(false)
|
||||||
|
// 下载完成后,触发页面刷新让组件重新尝试转写
|
||||||
|
// 通过更新缓存触发组件重新检查
|
||||||
|
if (pendingVoiceTranscriptRequest) {
|
||||||
|
// 清除缓存中的请求标记,让组件可以重新尝试
|
||||||
|
const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}`
|
||||||
|
// 不直接调用转写,而是让组件自己重试
|
||||||
|
// 通过触发一个自定义事件来通知所有 MessageBubble 组件
|
||||||
|
window.dispatchEvent(new CustomEvent('model-downloaded', {
|
||||||
|
detail: { messageId: pendingVoiceTranscriptRequest.messageId }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
setPendingVoiceTranscriptRequest(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1280,18 +1327,20 @@ 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>>()
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: {
|
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, onRequireModelDownload }: {
|
||||||
message: Message;
|
message: Message;
|
||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
myAvatarUrl?: string;
|
myAvatarUrl?: string;
|
||||||
isGroupChat?: boolean;
|
isGroupChat?: boolean;
|
||||||
|
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const isSystem = message.localType === 10000
|
const isSystem = isSystemMessage(message.localType)
|
||||||
const isEmoji = message.localType === 47
|
const isEmoji = message.localType === 47
|
||||||
const isImage = message.localType === 3
|
const isImage = message.localType === 3
|
||||||
const isVoice = message.localType === 34
|
const isVoice = message.localType === 34
|
||||||
@@ -1306,11 +1355,30 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
const [imageClicked, setImageClicked] = useState(false)
|
const [imageClicked, setImageClicked] = useState(false)
|
||||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||||
const imageClickTimerRef = useRef<number | null>(null)
|
const imageClickTimerRef = useRef<number | null>(null)
|
||||||
|
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imageAutoDecryptTriggered = useRef(false)
|
||||||
const [voiceError, setVoiceError] = useState(false)
|
const [voiceError, setVoiceError] = useState(false)
|
||||||
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)
|
||||||
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
|
||||||
|
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
|
||||||
|
const [voiceDuration, setVoiceDuration] = useState(0)
|
||||||
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
|
const voiceAutoDecryptTriggered = useRef(false)
|
||||||
|
|
||||||
|
// 加载自动转文字配置
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
const enabled = await configService.getAutoTranscribeVoice()
|
||||||
|
setAutoTranscribeVoice(enabled)
|
||||||
|
}
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 从缓存获取表情包 data URL
|
// 从缓存获取表情包 data URL
|
||||||
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
||||||
@@ -1325,6 +1393,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 '未知时间'
|
||||||
@@ -1555,6 +1627,31 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
}
|
}
|
||||||
}, [isImage, imageCacheKey, message.imageDatName, message.imageMd5])
|
}, [isImage, imageCacheKey, message.imageDatName, message.imageMd5])
|
||||||
|
|
||||||
|
// 图片进入视野前自动解密(懒加载)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isImage) return
|
||||||
|
if (imageLocalPath) return // 已有图片,不需要解密
|
||||||
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
|
|
||||||
|
const container = imageContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
// rootMargin 设置为 200px,提前触发解密
|
||||||
|
if (entry.isIntersecting && !imageAutoDecryptTriggered.current) {
|
||||||
|
imageAutoDecryptTriggered.current = true
|
||||||
|
void requestImageDecrypt()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px', threshold: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(container)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVoice) return
|
if (!isVoice) return
|
||||||
@@ -1565,18 +1662,192 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
if (!audio) return
|
if (!audio) return
|
||||||
const handlePlay = () => setIsVoicePlaying(true)
|
const handlePlay = () => setIsVoicePlaying(true)
|
||||||
const handlePause = () => setIsVoicePlaying(false)
|
const handlePause = () => setIsVoicePlaying(false)
|
||||||
const handleEnded = () => setIsVoicePlaying(false)
|
const handleEnded = () => {
|
||||||
|
setIsVoicePlaying(false)
|
||||||
|
setVoiceCurrentTime(0)
|
||||||
|
}
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setVoiceCurrentTime(audio.currentTime)
|
||||||
|
}
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setVoiceDuration(audio.duration)
|
||||||
|
}
|
||||||
audio.addEventListener('play', handlePlay)
|
audio.addEventListener('play', handlePlay)
|
||||||
audio.addEventListener('pause', handlePause)
|
audio.addEventListener('pause', handlePause)
|
||||||
audio.addEventListener('ended', handleEnded)
|
audio.addEventListener('ended', handleEnded)
|
||||||
|
audio.addEventListener('timeupdate', handleTimeUpdate)
|
||||||
|
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
return () => {
|
return () => {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
audio.removeEventListener('play', handlePlay)
|
audio.removeEventListener('play', handlePlay)
|
||||||
audio.removeEventListener('pause', handlePause)
|
audio.removeEventListener('pause', handlePause)
|
||||||
audio.removeEventListener('ended', handleEnded)
|
audio.removeEventListener('ended', handleEnded)
|
||||||
|
audio.removeEventListener('timeupdate', handleTimeUpdate)
|
||||||
|
audio.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
}
|
}
|
||||||
}, [isVoice])
|
}, [isVoice])
|
||||||
|
|
||||||
|
// 生成波形数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!voiceDataUrl) {
|
||||||
|
setVoiceWaveform([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateWaveform = async () => {
|
||||||
|
try {
|
||||||
|
// 从 data:audio/wav;base64,... 提取 base64
|
||||||
|
const base64 = voiceDataUrl.split(',')[1]
|
||||||
|
const binaryString = window.atob(base64)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
const audioBuffer = await audioCtx.decodeAudioData(bytes.buffer)
|
||||||
|
const rawData = audioBuffer.getChannelData(0) // 获取单声道数据
|
||||||
|
const samples = 35 // 波形柱子数量
|
||||||
|
const blockSize = Math.floor(rawData.length / samples)
|
||||||
|
const filteredData: number[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
let blockStart = blockSize * i
|
||||||
|
let sum = 0
|
||||||
|
for (let j = 0; j < blockSize; j++) {
|
||||||
|
sum = sum + Math.abs(rawData[blockStart + j])
|
||||||
|
}
|
||||||
|
filteredData.push(sum / blockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化
|
||||||
|
const multiplier = Math.pow(Math.max(...filteredData), -1)
|
||||||
|
const normalizedData = filteredData.map(n => n * multiplier)
|
||||||
|
setVoiceWaveform(normalizedData)
|
||||||
|
void audioCtx.close()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to generate waveform:', e)
|
||||||
|
// 降级:生成随机但平滑的波形
|
||||||
|
setVoiceWaveform(Array.from({ length: 35 }, () => 0.2 + Math.random() * 0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void generateWaveform()
|
||||||
|
}, [voiceDataUrl])
|
||||||
|
|
||||||
|
// 消息加载时自动检测语音缓存
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVoice || voiceDataUrl) return
|
||||||
|
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
|
||||||
|
.then(result => {
|
||||||
|
if (result.success && result.hasCache && result.data) {
|
||||||
|
const url = `data:audio/wav;base64,${result.data}`
|
||||||
|
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||||
|
setVoiceDataUrl(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [isVoice, message.localId, session.username, voiceCacheKey, voiceDataUrl])
|
||||||
|
|
||||||
|
// 监听流式转写结果
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVoice) return
|
||||||
|
const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => {
|
||||||
|
if (payload.msgId === String(message.localId)) {
|
||||||
|
setVoiceTranscript(payload.text)
|
||||||
|
voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => removeListener?.()
|
||||||
|
}, [isVoice, message.localId, voiceTranscriptCacheKey])
|
||||||
|
|
||||||
|
const requestVoiceTranscript = useCallback(async () => {
|
||||||
|
if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return
|
||||||
|
|
||||||
|
// 检查 whisper API 是否可用
|
||||||
|
if (!window.electronAPI?.whisper?.getModelStatus) {
|
||||||
|
console.warn('[ChatPage] whisper API 不可用')
|
||||||
|
setVoiceTranscriptError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
voiceTranscriptRequestedRef.current = true
|
||||||
|
setVoiceTranscriptLoading(true)
|
||||||
|
setVoiceTranscriptError(false)
|
||||||
|
try {
|
||||||
|
// 检查模型状态
|
||||||
|
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||||
|
if (!modelStatus?.exists) {
|
||||||
|
const error: any = new Error('MODEL_NOT_DOWNLOADED')
|
||||||
|
error.requiresDownload = true
|
||||||
|
error.sessionId = session.username
|
||||||
|
error.messageId = String(message.localId)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId))
|
||||||
|
if (result.success) {
|
||||||
|
const transcriptText = (result.transcript || '').trim()
|
||||||
|
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
|
||||||
|
setVoiceTranscript(transcriptText)
|
||||||
|
} else {
|
||||||
|
setVoiceTranscriptError(true)
|
||||||
|
voiceTranscriptRequestedRef.current = false
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 检查是否是模型未下载错误
|
||||||
|
if (error?.requiresDownload) {
|
||||||
|
// 模型未下载,触发下载弹窗
|
||||||
|
onRequireModelDownload?.(error.sessionId, error.messageId)
|
||||||
|
// 不要重置 voiceTranscriptRequestedRef,避免重复触发
|
||||||
|
setVoiceTranscriptLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVoiceTranscriptError(true)
|
||||||
|
voiceTranscriptRequestedRef.current = false
|
||||||
|
} finally {
|
||||||
|
setVoiceTranscriptLoading(false)
|
||||||
|
}
|
||||||
|
}, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload])
|
||||||
|
|
||||||
|
// 监听模型下载完成事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVoice) return
|
||||||
|
|
||||||
|
const handleModelDownloaded = (event: CustomEvent) => {
|
||||||
|
if (event.detail?.messageId === String(message.localId)) {
|
||||||
|
// 重置状态,允许重新尝试转写
|
||||||
|
voiceTranscriptRequestedRef.current = false
|
||||||
|
setVoiceTranscriptError(false)
|
||||||
|
// 立即尝试转写
|
||||||
|
void requestVoiceTranscript()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('model-downloaded', handleModelDownloaded as EventListener)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener)
|
||||||
|
}
|
||||||
|
}, [isVoice, message.localId, requestVoiceTranscript])
|
||||||
|
|
||||||
|
// 根据设置决定是否自动转写
|
||||||
|
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI.config.get('autoTranscribeVoice').then((value) => {
|
||||||
|
setAutoTranscribeEnabled(value === true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoTranscribeEnabled) return
|
||||||
|
if (!isVoice) return
|
||||||
|
if (!voiceDataUrl) return
|
||||||
|
if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字
|
||||||
|
if (voiceTranscriptError) return
|
||||||
|
if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return
|
||||||
|
void requestVoiceTranscript()
|
||||||
|
}, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
|
||||||
|
|
||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<div className="message-bubble system">
|
<div className="message-bubble system">
|
||||||
@@ -1637,15 +1908,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
// 渲染消息内容
|
// 渲染消息内容
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
if (imageLoading) {
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={imageContainerRef}>
|
||||||
|
{imageLoading ? (
|
||||||
<div className="image-loading">
|
<div className="image-loading">
|
||||||
<Loader2 size={20} className="spin" />
|
<Loader2 size={20} className="spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
) : imageError || !imageLocalPath ? (
|
||||||
}
|
|
||||||
if (imageError || !imageLocalPath) {
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
|
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
@@ -1656,9 +1925,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
<span>图片未解密</span>
|
<span>图片未解密</span>
|
||||||
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
|
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
) : (
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<div className="image-message-wrapper">
|
<div className="image-message-wrapper">
|
||||||
<img
|
<img
|
||||||
@@ -1687,6 +1954,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
|
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,7 +1976,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
setVoiceLoading(true)
|
setVoiceLoading(true)
|
||||||
setVoiceError(false)
|
setVoiceError(false)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId))
|
const result = await window.electronAPI.chat.getVoiceData(
|
||||||
|
session.username,
|
||||||
|
String(message.localId),
|
||||||
|
message.createTime,
|
||||||
|
message.serverId
|
||||||
|
)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const url = `data:audio/wav;base64,${result.data}`
|
const url = `data:audio/wav;base64,${result.data}`
|
||||||
voiceDataUrlCache.set(voiceCacheKey, url)
|
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||||
@@ -1736,9 +2010,33 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!voiceDataUrl || !voiceAudioRef.current) return
|
||||||
|
e.stopPropagation()
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const percentage = x / rect.width
|
||||||
|
const newTime = percentage * voiceDuration
|
||||||
|
voiceAudioRef.current.currentTime = newTime
|
||||||
|
setVoiceCurrentTime(newTime)
|
||||||
|
}
|
||||||
|
|
||||||
const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying
|
const 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-stack">
|
||||||
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
||||||
<button
|
<button
|
||||||
className="voice-play-btn"
|
className="voice-play-btn"
|
||||||
@@ -1751,13 +2049,31 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
>
|
>
|
||||||
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
|
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||||
</button>
|
</button>
|
||||||
<div className="voice-wave">
|
<div className="voice-wave" onClick={handleSeek}>
|
||||||
|
{voiceDataUrl && voiceWaveform.length > 0 ? (
|
||||||
|
<div className="voice-waveform">
|
||||||
|
{voiceWaveform.map((amplitude, i) => {
|
||||||
|
const progress = (voiceCurrentTime / (voiceDuration || 1))
|
||||||
|
const isPlayed = (i / voiceWaveform.length) < progress
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`waveform-bar ${isPlayed ? 'played' : ''}`}
|
||||||
|
style={{ height: `${Math.max(20, amplitude * 100)}%` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="voice-wave-placeholder">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="voice-info">
|
<div className="voice-info">
|
||||||
<span className="voice-label">语音</span>
|
<span className="voice-label">语音</span>
|
||||||
{durationText && <span className="voice-duration">{durationText}</span>}
|
{durationText && <span className="voice-duration">{durationText}</span>}
|
||||||
@@ -1765,6 +2081,41 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
||||||
{voiceError && <span className="voice-error">播放失败</span>}
|
{voiceError && <span className="voice-error">播放失败</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 转文字按钮 */}
|
||||||
|
{voiceDataUrl && !voiceTranscript && !voiceTranscriptLoading && (
|
||||||
|
<button
|
||||||
|
className="voice-transcribe-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
void requestVoiceTranscript()
|
||||||
|
}}
|
||||||
|
title="转文字"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showTranscript && (
|
||||||
|
<div
|
||||||
|
className={`voice-transcript ${isSent ? 'sent' : 'received'}${voiceTranscriptError ? ' error' : ''}`}
|
||||||
|
onClick={handleTranscriptRetry}
|
||||||
|
title={voiceTranscriptError ? '点击重试语音转写' : undefined}
|
||||||
|
>
|
||||||
|
{voiceTranscriptError ? (
|
||||||
|
'转写失败,点击重试'
|
||||||
|
) : !voiceTranscript ? (
|
||||||
|
voiceTranscriptLoading ? '转写中...' : '未识别到文字'
|
||||||
|
) : (
|
||||||
|
<AnimatedStreamingText
|
||||||
|
text={transcriptText}
|
||||||
|
loading={voiceTranscriptLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -920,3 +920,140 @@
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 媒体导出选项卡片样式
|
||||||
|
.setting-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 4px 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-options-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-switch-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-option-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-checkbox-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch 开关样式
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 24px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ interface ExportOptions {
|
|||||||
dateRange: { start: Date; end: Date } | null
|
dateRange: { start: Date; end: Date } | null
|
||||||
useAllTime: boolean
|
useAllTime: boolean
|
||||||
exportAvatars: boolean
|
exportAvatars: boolean
|
||||||
|
exportMedia: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
exportVoiceAsText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportResult {
|
interface ExportResult {
|
||||||
@@ -46,7 +51,12 @@ function ExportPage() {
|
|||||||
end: new Date()
|
end: new Date()
|
||||||
},
|
},
|
||||||
useAllTime: true,
|
useAllTime: true,
|
||||||
exportAvatars: true
|
exportAvatars: true,
|
||||||
|
exportMedia: false,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
@@ -146,6 +156,11 @@ function ExportPage() {
|
|||||||
const exportOptions = {
|
const exportOptions = {
|
||||||
format: options.format,
|
format: options.format,
|
||||||
exportAvatars: options.exportAvatars,
|
exportAvatars: options.exportAvatars,
|
||||||
|
exportMedia: options.exportMedia,
|
||||||
|
exportImages: options.exportMedia && options.exportImages,
|
||||||
|
exportVoices: options.exportMedia && options.exportVoices,
|
||||||
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
|
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
|
||||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||||
@@ -153,7 +168,7 @@ function ExportPage() {
|
|||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') {
|
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
|
||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
sessionList,
|
sessionList,
|
||||||
exportFolder,
|
exportFolder,
|
||||||
@@ -203,18 +218,54 @@ function ExportPage() {
|
|||||||
const year = calendarDate.getFullYear()
|
const year = calendarDate.getFullYear()
|
||||||
const month = calendarDate.getMonth()
|
const month = calendarDate.getMonth()
|
||||||
const selectedDate = new Date(year, month, day)
|
const selectedDate = new Date(year, month, day)
|
||||||
|
// 设置时间为当天的开始或结束
|
||||||
|
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
// 如果选择的日期晚于当前时间,限制为当前时间
|
||||||
|
if (selectedDate > now) {
|
||||||
|
selectedDate.setTime(now.getTime())
|
||||||
|
}
|
||||||
|
|
||||||
if (selectingStart) {
|
if (selectingStart) {
|
||||||
|
// 选择开始日期
|
||||||
|
const currentEnd = options.dateRange?.end || new Date()
|
||||||
|
// 如果选择的开始日期晚于结束日期,则同时更新结束日期
|
||||||
|
if (selectedDate > currentEnd) {
|
||||||
|
const newEnd = new Date(selectedDate)
|
||||||
|
newEnd.setHours(23, 59, 59, 999)
|
||||||
|
// 确保结束日期也不晚于当前时间
|
||||||
|
if (newEnd > now) {
|
||||||
|
newEnd.setTime(now.getTime())
|
||||||
|
}
|
||||||
|
setOptions({
|
||||||
|
...options,
|
||||||
|
dateRange: { start: selectedDate, end: newEnd }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
setOptions({
|
setOptions({
|
||||||
...options,
|
...options,
|
||||||
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
|
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
|
||||||
})
|
})
|
||||||
|
}
|
||||||
setSelectingStart(false)
|
setSelectingStart(false)
|
||||||
|
} else {
|
||||||
|
// 选择结束日期
|
||||||
|
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
// 如果选择的结束日期早于开始日期,则同时更新开始日期
|
||||||
|
if (selectedDate < currentStart) {
|
||||||
|
const newStart = new Date(selectedDate)
|
||||||
|
newStart.setHours(0, 0, 0, 0)
|
||||||
|
setOptions({
|
||||||
|
...options,
|
||||||
|
dateRange: { start: newStart, end: selectedDate }
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setOptions({
|
setOptions({
|
||||||
...options,
|
...options,
|
||||||
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
|
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
|
||||||
})
|
})
|
||||||
|
}
|
||||||
setSelectingStart(true)
|
setSelectingStart(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,18 +394,105 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<h3>导出头像</h3>
|
<h3>媒体文件</h3>
|
||||||
<div className="time-options">
|
<p className="setting-subtitle">导出图片/语音/表情并在记录内写入相对路径</p>
|
||||||
<label className="checkbox-item">
|
<div className="media-options-card">
|
||||||
|
<div className="media-switch-row">
|
||||||
|
<div className="media-switch-info">
|
||||||
|
<span className="media-switch-title">导出媒体文件</span>
|
||||||
|
<span className="media-switch-desc">会创建子文件夹并保存媒体资源</span>
|
||||||
|
</div>
|
||||||
|
<label className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
|
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||||
|
<div className="media-checkbox-info">
|
||||||
|
<span className="media-checkbox-title">图片</span>
|
||||||
|
<span className="media-checkbox-desc">已有文件直接复制,缺失时尝试解密</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportImages}
|
||||||
|
disabled={!options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
|
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||||
|
<div className="media-checkbox-info">
|
||||||
|
<span className="media-checkbox-title">语音</span>
|
||||||
|
<span className="media-checkbox-desc">缺失时会解码生成 MP3</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportVoices}
|
||||||
|
disabled={!options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
|
<label className="media-checkbox-row">
|
||||||
|
<div className="media-checkbox-info">
|
||||||
|
<span className="media-checkbox-title">语音转文字</span>
|
||||||
|
<span className="media-checkbox-desc">将语音消息转换为文字导出(不导出语音文件)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportVoiceAsText}
|
||||||
|
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="media-option-divider"></div>
|
||||||
|
|
||||||
|
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||||
|
<div className="media-checkbox-info">
|
||||||
|
<span className="media-checkbox-title">表情</span>
|
||||||
|
<span className="media-checkbox-desc">本地无缓存时尝试下载</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={options.exportEmojis}
|
||||||
|
disabled={!options.exportMedia}
|
||||||
|
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<h3>头像</h3>
|
||||||
|
<p className="setting-subtitle">可选导出头像索引,关闭则不下载头像</p>
|
||||||
|
<div className="media-options-card">
|
||||||
|
<div className="media-switch-row">
|
||||||
|
<div className="media-switch-info">
|
||||||
|
<span className="media-switch-title">导出头像</span>
|
||||||
|
<span className="media-switch-desc">用于展示发送者头像,可能会读取或下载头像文件</span>
|
||||||
|
</div>
|
||||||
|
<label className="switch">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={options.exportAvatars}
|
checked={options.exportAvatars}
|
||||||
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<span>导出头像图片</span>
|
<span className="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<h3>导出位置</h3>
|
<h3>导出位置</h3>
|
||||||
@@ -462,6 +600,9 @@ function ExportPage() {
|
|||||||
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
||||||
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
||||||
<h3>选择时间范围</h3>
|
<h3>选择时间范围</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
|
||||||
|
点击选择开始和结束日期,系统会自动调整确保时间顺序正确
|
||||||
|
</p>
|
||||||
<div className="quick-select">
|
<div className="quick-select">
|
||||||
<button
|
<button
|
||||||
className="quick-btn"
|
className="quick-btn"
|
||||||
@@ -556,12 +697,16 @@ function ExportPage() {
|
|||||||
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
||||||
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
||||||
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const isFuture = currentDate > today
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
|
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
|
||||||
onClick={() => handleDateSelect(day)}
|
onClick={() => !isFuture && handleDateSelect(day)}
|
||||||
|
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,6 +204,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-with-toggle {
|
.input-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -235,6 +252,184 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whisper-section {
|
||||||
|
background: color-mix(in srgb, var(--primary) 3%, transparent);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.warn {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.whisper-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%);
|
||||||
|
animation: progress-shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.percent {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-model {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover svg {
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-shimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.log-toggle-line {
|
.log-toggle-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -248,6 +443,72 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .checkbox-custom {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked+.checkbox-custom {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active .checkbox-custom {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.switch {
|
.switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 46px;
|
width: 46px;
|
||||||
@@ -287,12 +548,12 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-input:checked + .switch-slider {
|
.switch-input:checked+.switch-slider {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-input:checked + .switch-slider::before {
|
.switch-input:checked+.switch-slider::before {
|
||||||
transform: translateX(22px);
|
transform: translateX(22px);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -331,18 +592,33 @@
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
|
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
|
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--border-color);
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +671,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%) translateY(-10px);
|
transform: translateX(-50%) translateY(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
@@ -402,9 +679,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
@@ -667,8 +947,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
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 { dialog } from '../services/ipc'
|
import { dialog } from '../services/ipc'
|
||||||
import * as configService from '../services/config'
|
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 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 }
|
||||||
]
|
]
|
||||||
@@ -27,6 +29,7 @@ interface WxidOption {
|
|||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { setDbConnected, setLoading, reset } = useAppStore()
|
const { setDbConnected, setLoading, reset } = useAppStore()
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
|
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||||
const [decryptKey, setDecryptKey] = useState('')
|
const [decryptKey, setDecryptKey] = useState('')
|
||||||
@@ -39,6 +42,13 @@ 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 [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||||
|
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||||
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
const [isLoading, setIsLoadingState] = useState(false)
|
const [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
@@ -55,6 +65,11 @@ function SettingsPage() {
|
|||||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||||
|
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||||
|
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||||
|
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||||
|
|
||||||
|
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
@@ -95,6 +110,10 @@ 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 savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||||
|
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||||
|
|
||||||
if (savedKey) setDecryptKey(savedKey)
|
if (savedKey) setDecryptKey(savedKey)
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
@@ -105,6 +124,17 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
||||||
setLogEnabled(savedLogEnabled)
|
setLogEnabled(savedLogEnabled)
|
||||||
|
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||||
|
setTranscribeLanguages(savedTranscribeLanguages)
|
||||||
|
|
||||||
|
// 如果语言列表为空,保存默认值
|
||||||
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
|
const defaultLanguages = ['zh']
|
||||||
|
setTranscribeLanguages(defaultLanguages)
|
||||||
|
await configService.setTranscribeLanguages(defaultLanguages)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -112,6 +142,21 @@ function SettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||||
|
if (result?.success) {
|
||||||
|
setWhisperModelStatus({
|
||||||
|
exists: Boolean(result.exists),
|
||||||
|
modelPath: result.modelPath,
|
||||||
|
tokensPath: result.tokensPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setWhisperModelStatus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadAppVersion = async () => {
|
const loadAppVersion = async () => {
|
||||||
try {
|
try {
|
||||||
const version = await window.electronAPI.app.getVersion()
|
const version = await window.electronAPI.app.getVersion()
|
||||||
@@ -129,6 +174,19 @@ function SettingsPage() {
|
|||||||
return () => removeListener?.()
|
return () => removeListener?.()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||||
|
if (typeof payload.percent === 'number') {
|
||||||
|
setWhisperDownloadProgress(payload.percent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => removeListener?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshWhisperStatus(whisperModelDir)
|
||||||
|
}, [whisperModelDir])
|
||||||
|
|
||||||
const handleCheckUpdate = async () => {
|
const handleCheckUpdate = async () => {
|
||||||
setIsCheckingUpdate(true)
|
setIsCheckingUpdate(true)
|
||||||
setUpdateInfo(null)
|
setUpdateInfo(null)
|
||||||
@@ -136,9 +194,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)
|
||||||
@@ -250,6 +308,51 @@ function SettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleSelectWhisperModelDir = async () => {
|
||||||
|
try {
|
||||||
|
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
const dir = result.filePaths[0]
|
||||||
|
setWhisperModelDir(dir)
|
||||||
|
await configService.setWhisperModelDir(dir)
|
||||||
|
showMessage('已选择 Whisper 模型目录', true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('选择目录失败', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWhisperModelChange = async (value: string) => {
|
||||||
|
setWhisperModelName(value)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
await configService.setWhisperModelName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadWhisperModel = async () => {
|
||||||
|
if (isWhisperDownloading) return
|
||||||
|
setIsWhisperDownloading(true)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.whisper.downloadModel()
|
||||||
|
if (result.success) {
|
||||||
|
setWhisperDownloadProgress(100)
|
||||||
|
showMessage('SenseVoiceSmall 模型下载完成', true)
|
||||||
|
await refreshWhisperStatus(whisperModelDir)
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '模型下载失败', false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`模型下载失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsWhisperDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetWhisperModelDir = async () => {
|
||||||
|
setWhisperModelDir('')
|
||||||
|
await configService.setWhisperModelDir('')
|
||||||
|
}
|
||||||
|
|
||||||
const handleAutoGetDbKey = async () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
if (isFetchingDbKey) return
|
if (isFetchingDbKey) return
|
||||||
setIsFetchingDbKey(true)
|
setIsFetchingDbKey(true)
|
||||||
@@ -360,6 +463,9 @@ function SettingsPage() {
|
|||||||
} else {
|
} else {
|
||||||
await configService.setImageAesKey('')
|
await configService.setImageAesKey('')
|
||||||
}
|
}
|
||||||
|
await configService.setWhisperModelDir(whisperModelDir)
|
||||||
|
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
|
||||||
|
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|
||||||
showMessage('配置保存成功,正在测试连接...', true)
|
showMessage('配置保存成功,正在测试连接...', true)
|
||||||
@@ -380,7 +486,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, '正在清除配置...')
|
||||||
@@ -395,6 +501,12 @@ function SettingsPage() {
|
|||||||
setWxid('')
|
setWxid('')
|
||||||
setCachePath('')
|
setCachePath('')
|
||||||
setLogEnabled(false)
|
setLogEnabled(false)
|
||||||
|
setAutoTranscribeVoice(false)
|
||||||
|
setTranscribeLanguages(['zh'])
|
||||||
|
setWhisperModelDir('')
|
||||||
|
setWhisperModelStatus(null)
|
||||||
|
setWhisperDownloadProgress(0)
|
||||||
|
setIsWhisperDownloading(false)
|
||||||
setDbConnected(false)
|
setDbConnected(false)
|
||||||
await window.electronAPI.window.openOnboardingWindow()
|
await window.electronAPI.window.openOnboardingWindow()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -428,6 +540,59 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearAnalyticsCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingAnalyticsCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearAnalytics()
|
||||||
|
if (result.success) {
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
showMessage('已清除分析缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除分析缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingAnalyticsCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearImageCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingImageCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearImages()
|
||||||
|
if (result.success) {
|
||||||
|
showMessage('已清除图片缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除图片缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingImageCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAllCache = async () => {
|
||||||
|
if (isClearingCache) return
|
||||||
|
setIsClearingAllCache(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cache.clearAll()
|
||||||
|
if (result.success) {
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
showMessage('已清除所有缓存', true)
|
||||||
|
} else {
|
||||||
|
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(`清除所有缓存失败: ${e}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsClearingAllCache(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderAppearanceTab = () => (
|
const renderAppearanceTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="theme-mode-toggle">
|
<div className="theme-mode-toggle">
|
||||||
@@ -484,6 +649,7 @@ function SettingsPage() {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>数据库根目录</label>
|
<label>数据库根目录</label>
|
||||||
<span className="form-hint">xwechat_files 目录</span>
|
<span className="form-hint">xwechat_files 目录</span>
|
||||||
|
<span className="form-hint" style={{ color: '#ff6b6b' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span>
|
||||||
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||||
@@ -548,16 +714,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>
|
||||||
@@ -590,16 +746,135 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
const renderWhisperTab = () => (
|
||||||
|
<div className="tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>自动语音转文字</label>
|
||||||
|
<span className="form-hint">语音解密后自动转写为文字(需下载模型)</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="auto-transcribe-toggle">
|
||||||
|
<input
|
||||||
|
id="auto-transcribe-toggle"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoTranscribeVoice}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setAutoTranscribeVoice(enabled)
|
||||||
|
await configService.setAutoTranscribeVoice(enabled)
|
||||||
|
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>支持的语言</label>
|
||||||
|
<span className="form-hint">选择需要识别的语言(至少选择一种)</span>
|
||||||
|
<div className="language-checkboxes">
|
||||||
|
{[
|
||||||
|
{ code: 'zh', name: '中文' },
|
||||||
|
{ code: 'yue', name: '粤语' },
|
||||||
|
{ code: 'en', name: '英文' },
|
||||||
|
{ code: 'ja', name: '日文' },
|
||||||
|
{ code: 'ko', name: '韩文' }
|
||||||
|
].map((lang) => (
|
||||||
|
<label key={lang.code} className="language-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={transcribeLanguages.includes(lang.code)}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const checked = e.target.checked
|
||||||
|
let newLanguages: string[]
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
newLanguages = [...transcribeLanguages, lang.code]
|
||||||
|
} else {
|
||||||
|
if (transcribeLanguages.length <= 1) {
|
||||||
|
showMessage('至少需要选择一种语言', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranscribeLanguages(newLanguages)
|
||||||
|
await configService.setTranscribeLanguages(newLanguages)
|
||||||
|
showMessage(`已${checked ? '添加' : '移除'}${lang.name}`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="checkbox-custom">
|
||||||
|
<Check size={14} />
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group whisper-section">
|
||||||
|
<label>语音识别模型 (SenseVoiceSmall)</label>
|
||||||
|
<span className="form-hint">基于 Sherpa-onnx,支持中、粤、英、日、韩及情感/事件识别</span>
|
||||||
|
<span className="form-hint">模型下载目录</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="留空使用默认目录"
|
||||||
|
value={whisperModelDir}
|
||||||
|
onChange={(e) => setWhisperModelDir(e.target.value)}
|
||||||
|
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
|
||||||
|
/>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> 默认目录</button>
|
||||||
|
</div>
|
||||||
|
<div className="whisper-status-line">
|
||||||
|
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
|
||||||
|
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
|
||||||
|
</span>
|
||||||
|
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
|
||||||
|
</div>
|
||||||
|
{isWhisperDownloading ? (
|
||||||
|
<div className="whisper-progress">
|
||||||
|
<div className="progress-info">
|
||||||
|
<span>正在准备模型文件...</span>
|
||||||
|
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
|
||||||
|
<Download size={18} /> 下载模型
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
const renderCacheTab = () => (
|
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">
|
<div className="btn-row">
|
||||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除分析缓存</button>
|
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除图片缓存</button>
|
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||||
<button className="btn btn-danger"><Trash2 size={16} /> 清除所有缓存</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
||||||
|
<Trash2 size={16} /> 清除分析缓存
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
|
||||||
|
<Trash2 size={16} /> 清除图片缓存
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
|
||||||
|
<Trash2 size={16} /> 清除所有缓存</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
||||||
@@ -624,7 +899,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">
|
||||||
@@ -681,7 +956,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>
|
||||||
@@ -716,6 +991,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>
|
||||||
@@ -724,3 +1000,5 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPage
|
export default SettingsPage
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
||||||
|
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ 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',
|
||||||
|
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||||
|
TRANSCRIBE_LANGUAGES: 'transcribeLanguages'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
@@ -144,6 +149,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()
|
||||||
@@ -182,3 +220,26 @@ export async function getOnboardingDone(): Promise<boolean> {
|
|||||||
export async function setOnboardingDone(done: boolean): Promise<void> {
|
export async function setOnboardingDone(done: boolean): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取自动语音转文字开关
|
||||||
|
export async function getAutoTranscribeVoice(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置自动语音转文字开关
|
||||||
|
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取语音转文字支持的语言列表
|
||||||
|
export async function getTranscribeLanguages(): Promise<string[]> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.TRANSCRIBE_LANGUAGES)
|
||||||
|
// 默认只支持中文
|
||||||
|
return (value as string[]) || ['zh']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置语音转文字支持的语言列表
|
||||||
|
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
|
||||||
|
}
|
||||||
|
|||||||
15
src/types/electron.d.ts
vendored
15
src/types/electron.d.ts
vendored
@@ -94,7 +94,10 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
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, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
|
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||||
|
getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||||
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
@@ -148,6 +151,11 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
cache: {
|
||||||
|
clearAnalytics: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
clearImages: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
clearAll: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
}
|
||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => Promise<{
|
getGroupChats: () => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -290,6 +298,11 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
whisper: {
|
||||||
|
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||||
|
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||||
|
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
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,10 @@ export default defineConfig({
|
|||||||
external: [
|
external: [
|
||||||
'better-sqlite3',
|
'better-sqlite3',
|
||||||
'koffi',
|
'koffi',
|
||||||
'fsevents'
|
'fsevents',
|
||||||
|
'whisper-node',
|
||||||
|
'shelljs',
|
||||||
|
'exceljs'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +90,23 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/transcribeWorker.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'sherpa-onnx-node'
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'transcribeWorker.js',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
entry: 'electron/preload.ts',
|
entry: 'electron/preload.ts',
|
||||||
onstart(options) {
|
onstart(options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user