mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-20 14:39:25 +08:00
Merge branch 'dev'
This commit is contained in:
179
electron/main.ts
179
electron/main.ts
@@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -200,6 +201,107 @@ function createOnboardingWindow() {
|
||||
return onboardingWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建独立的视频播放窗口
|
||||
* 窗口大小会根据视频比例自动调整
|
||||
*/
|
||||
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const { screen } = require('electron')
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||
|
||||
// 计算窗口尺寸,只有标题栏 40px,控制栏悬浮
|
||||
let winWidth = 854
|
||||
let winHeight = 520
|
||||
const titleBarHeight = 40
|
||||
|
||||
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
|
||||
const aspectRatio = videoWidth / videoHeight
|
||||
|
||||
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// 横向视频
|
||||
winWidth = Math.min(videoWidth, maxWidth)
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
|
||||
if (winHeight > maxHeight) {
|
||||
winHeight = maxHeight
|
||||
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||
}
|
||||
} else {
|
||||
// 竖向视频
|
||||
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||
winHeight = videoDisplayHeight + titleBarHeight
|
||||
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||
|
||||
if (winWidth < 300) {
|
||||
winWidth = 300
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
winWidth = Math.max(winWidth, 360)
|
||||
winHeight = Math.max(winHeight, 280)
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: winWidth,
|
||||
height: winHeight,
|
||||
minWidth: 360,
|
||||
minHeight: 280,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webSecurity: false
|
||||
},
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#1a1a1a',
|
||||
symbolColor: '#ffffff',
|
||||
height: 40
|
||||
},
|
||||
show: false,
|
||||
backgroundColor: '#000000',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
|
||||
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/video-player-window?${videoParam}`
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
shouldShowMain = true
|
||||
if (mainWindowReady) {
|
||||
@@ -356,6 +458,79 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
// 打开视频播放窗口
|
||||
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
|
||||
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
|
||||
})
|
||||
|
||||
// 根据视频尺寸调整窗口大小
|
||||
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win || !videoWidth || !videoHeight) return
|
||||
|
||||
const { screen } = require('electron')
|
||||
const primaryDisplay = screen.getPrimaryDisplay()
|
||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
|
||||
|
||||
// 只有标题栏 40px,控制栏悬浮在视频上
|
||||
const titleBarHeight = 40
|
||||
const aspectRatio = videoWidth / videoHeight
|
||||
|
||||
const maxWidth = Math.floor(screenWidth * 0.85)
|
||||
const maxHeight = Math.floor(screenHeight * 0.85)
|
||||
|
||||
let winWidth: number
|
||||
let winHeight: number
|
||||
|
||||
if (aspectRatio >= 1) {
|
||||
// 横向视频 - 以宽度为基准
|
||||
winWidth = Math.min(videoWidth, maxWidth)
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
|
||||
if (winHeight > maxHeight) {
|
||||
winHeight = maxHeight
|
||||
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
|
||||
}
|
||||
} else {
|
||||
// 竖向视频 - 以高度为基准
|
||||
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
|
||||
winHeight = videoDisplayHeight + titleBarHeight
|
||||
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
|
||||
|
||||
// 确保宽度不会太窄
|
||||
if (winWidth < 300) {
|
||||
winWidth = 300
|
||||
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
|
||||
}
|
||||
}
|
||||
|
||||
winWidth = Math.max(winWidth, 360)
|
||||
winHeight = Math.max(winHeight, 280)
|
||||
|
||||
// 调整窗口大小并居中
|
||||
win.setSize(winWidth, winHeight)
|
||||
win.center()
|
||||
})
|
||||
|
||||
// 视频相关
|
||||
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
|
||||
try {
|
||||
const result = await videoService.getVideoInfo(videoMd5)
|
||||
return { success: true, ...result }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e), exists: false }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
|
||||
try {
|
||||
const md5 = videoService.parseVideoMd5(content)
|
||||
return { success: true, md5 }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
// 数据库路径相关
|
||||
ipcMain.handle('dbpath:autoDetect', async () => {
|
||||
return dbPathService.autoDetect()
|
||||
@@ -446,8 +621,8 @@ function registerIpcHandlers() {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, (text) => {
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
|
||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options)
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
|
||||
},
|
||||
|
||||
// 数据库路径
|
||||
@@ -109,7 +113,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
@@ -137,6 +141,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface Message {
|
||||
senderUsername: string | null
|
||||
parsedContent: string
|
||||
rawContent: string
|
||||
content?: string // 原始XML内容(与rawContent相同,供前端使用)
|
||||
// 表情包相关
|
||||
emojiCdnUrl?: string
|
||||
emojiMd5?: string
|
||||
@@ -52,6 +53,7 @@ export interface Message {
|
||||
// 图片/视频相关
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
videoMd5?: string
|
||||
aesKey?: string
|
||||
encrypVer?: number
|
||||
cdnThumbUrl?: string
|
||||
@@ -83,7 +85,18 @@ class ChatService {
|
||||
private voiceWavCache = new Map<string, Buffer>()
|
||||
private voiceTranscriptCache = new Map<string, string>()
|
||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||
private mediaDbsCache: string[] | null = null
|
||||
private mediaDbsCacheTime = 0
|
||||
private readonly mediaDbsCacheTtl = 300000 // 5分钟
|
||||
private readonly voiceCacheMaxEntries = 50
|
||||
// 缓存 media.db 的表结构信息
|
||||
private mediaDbSchemaCache = new Map<string, {
|
||||
voiceTable: string
|
||||
dataColumn: string
|
||||
chatNameIdColumn?: string
|
||||
timeColumn?: string
|
||||
name2IdTable?: string
|
||||
}>()
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
@@ -140,6 +153,10 @@ class ChatService {
|
||||
}
|
||||
|
||||
this.connected = true
|
||||
|
||||
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
|
||||
this.warmupMediaDbsCache()
|
||||
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 连接数据库失败:', e)
|
||||
@@ -147,6 +164,21 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热 media 数据库列表缓存(后台异步执行)
|
||||
*/
|
||||
private async warmupMediaDbsCache(): Promise<void> {
|
||||
try {
|
||||
const result = await wcdbService.listMediaDbs()
|
||||
if (result.success && result.data) {
|
||||
this.mediaDbsCache = result.data as string[]
|
||||
this.mediaDbsCacheTime = Date.now()
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
@@ -382,8 +414,6 @@ class ChatService {
|
||||
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
|
||||
|
||||
if (needNewCursor) {
|
||||
console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`)
|
||||
|
||||
// 关闭旧游标
|
||||
if (state) {
|
||||
try {
|
||||
@@ -440,7 +470,6 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 获取当前批次的消息
|
||||
console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`)
|
||||
const batch = await wcdbService.fetchMessageBatch(state.cursor)
|
||||
if (!batch.success) {
|
||||
console.error('[ChatService] 获取消息批次失败:', batch.error)
|
||||
@@ -716,6 +745,7 @@ class ChatService {
|
||||
let quotedSender: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
let aesKey: string | undefined
|
||||
let encrypVer: number | undefined
|
||||
let cdnThumbUrl: string | undefined
|
||||
@@ -732,6 +762,9 @@ class ChatService {
|
||||
encrypVer = imageInfo.encrypVer
|
||||
cdnThumbUrl = imageInfo.cdnThumbUrl
|
||||
imageDatName = this.parseImageDatNameFromRow(row)
|
||||
} else if (localType === 43 && content) {
|
||||
// 视频消息
|
||||
videoMd5 = this.parseVideoMd5(content)
|
||||
} else if (localType === 34 && content) {
|
||||
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
@@ -756,6 +789,7 @@ class ChatService {
|
||||
quotedSender,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
voiceDurationSeconds,
|
||||
aesKey,
|
||||
encrypVer,
|
||||
@@ -937,6 +971,26 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析视频MD5
|
||||
* 注意:提取 md5 字段用于查询 hardlink.db,获取实际视频文件名
|
||||
*/
|
||||
private parseVideoMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
|
||||
try {
|
||||
// 提取 md5,这是用于查询 hardlink.db 的值
|
||||
const md5 =
|
||||
this.extractXmlAttribute(content, 'videomsg', 'md5') ||
|
||||
this.extractXmlValue(content, 'md5') ||
|
||||
undefined
|
||||
|
||||
return md5?.toLowerCase()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析通话消息
|
||||
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
||||
@@ -1419,21 +1473,22 @@ class ChatService {
|
||||
}
|
||||
|
||||
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i')
|
||||
const tagMatch = tagRegex.exec(xml)
|
||||
if (!tagMatch) return ''
|
||||
|
||||
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
|
||||
const attrMatch = attrRegex.exec(tagMatch[0])
|
||||
return attrMatch ? attrMatch[1] : ''
|
||||
// 匹配 <tagName ... attrName="value" ... /> 或 <tagName ... attrName="value" ...>
|
||||
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i')
|
||||
const match = regex.exec(xml)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
private cleanSystemMessage(content: string): string {
|
||||
return content
|
||||
.replace(/<img[^>]*>/gi, '')
|
||||
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '[系统消息]'
|
||||
// 移除 XML 声明
|
||||
let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
|
||||
// 移除所有 XML/HTML 标签
|
||||
cleaned = cleaned.replace(/<[^>]+>/g, '')
|
||||
// 移除尾部的数字(如撤回消息后的时间戳)
|
||||
cleaned = cleaned.replace(/\d+\s*$/, '')
|
||||
// 清理多余空白
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim()
|
||||
return cleaned || '[系统消息]'
|
||||
}
|
||||
|
||||
private stripSenderPrefix(content: string): string {
|
||||
@@ -1691,21 +1746,17 @@ class ChatService {
|
||||
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
|
||||
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
|
||||
|
||||
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
|
||||
const result = await wcdbService.getAvatarUrls(fetchList)
|
||||
|
||||
if (result.success && result.map) {
|
||||
// 按优先级尝试匹配
|
||||
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
|
||||
if (avatarUrl) {
|
||||
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
|
||||
return { success: true, avatarUrl }
|
||||
}
|
||||
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
|
||||
return { success: true, avatarUrl: undefined }
|
||||
}
|
||||
|
||||
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
|
||||
return { success: true, avatarUrl: undefined }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取当前用户头像失败:', e)
|
||||
@@ -1716,6 +1767,19 @@ class ChatService {
|
||||
/**
|
||||
* 获取表情包缓存目录
|
||||
*/
|
||||
/**
|
||||
* 获取语音缓存目录
|
||||
*/
|
||||
private getVoiceCacheDir(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (cachePath) {
|
||||
return join(cachePath, 'Voices')
|
||||
}
|
||||
// 回退到默认目录
|
||||
const documentsPath = app.getPath('documents')
|
||||
return join(documentsPath, 'WeFlow', 'Voices')
|
||||
}
|
||||
|
||||
private getEmojiCacheDir(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (cachePath) {
|
||||
@@ -2085,12 +2149,6 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息' }
|
||||
}
|
||||
const msg = msgResult.message
|
||||
console.info('[ChatService][Image] request', {
|
||||
sessionId,
|
||||
localId: msg.localId,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName
|
||||
})
|
||||
|
||||
// 2. 确定搜索的基础名
|
||||
const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId)
|
||||
@@ -2107,7 +2165,6 @@ class ChatService {
|
||||
|
||||
const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId)
|
||||
if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' }
|
||||
console.info('[ChatService][Image] dat path', datPath)
|
||||
|
||||
// 4. 获取解密密钥
|
||||
const xorKeyRaw = this.configService.get('imageXorKey')
|
||||
@@ -2135,7 +2192,6 @@ class ChatService {
|
||||
const aesKey = this.asciiKey16(trimmed)
|
||||
decrypted = this.decryptDatV4(data, xorKey, aesKey)
|
||||
}
|
||||
console.info('[ChatService][Image] decrypted bytes', decrypted.length)
|
||||
|
||||
// 返回 base64
|
||||
return { success: true, data: decrypted.toString('base64') }
|
||||
@@ -2146,44 +2202,30 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* getVoiceData (优化的 C++ 实现 + 文件缓存)
|
||||
* getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取)
|
||||
*/
|
||||
async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const localId = parseInt(msgId, 10)
|
||||
if (isNaN(localId)) {
|
||||
return { success: false, error: '无效的消息ID' }
|
||||
}
|
||||
|
||||
// 检查文件缓存
|
||||
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
|
||||
let senderWxid: string | null = null
|
||||
|
||||
// 如果提供了传来的参数,验证其有效性
|
||||
if (!msgCreateTime || msgCreateTime === 0) {
|
||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
||||
if (!msgCreateTime) {
|
||||
const t1 = Date.now()
|
||||
const msgResult = await this.getMessageByLocalId(sessionId, localId)
|
||||
const t2 = Date.now()
|
||||
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
|
||||
|
||||
if (msgResult.success && msgResult.message) {
|
||||
const msg = msgResult.message as any
|
||||
msgCreateTime = msg.createTime || msg.create_time
|
||||
// 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找)
|
||||
if (!msgSvrId || msgSvrId === 0) {
|
||||
msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0
|
||||
}
|
||||
msgCreateTime = msg.createTime
|
||||
senderWxid = msg.senderUsername || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2191,54 +2233,84 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息时间戳' }
|
||||
}
|
||||
|
||||
// 2. 构建查找候选 (sessionId, myWxid)
|
||||
// 使用 sessionId + createTime 作为缓存key
|
||||
const cacheKey = `${sessionId}_${msgCreateTime}`
|
||||
|
||||
// 检查 WAV 内存缓存
|
||||
const wavCache = this.voiceWavCache.get(cacheKey)
|
||||
if (wavCache) {
|
||||
console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavCache.toString('base64') }
|
||||
}
|
||||
|
||||
// 检查 WAV 文件缓存
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
const wavData = readFileSync(wavFilePath)
|
||||
// 同时缓存到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('[Voice] 读取缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建查找候选
|
||||
const candidates: string[] = []
|
||||
if (sessionId) candidates.push(sessionId)
|
||||
const myWxid = this.configService.get('myWxid') as string
|
||||
|
||||
// 如果有 senderWxid,优先使用(群聊中最重要)
|
||||
if (senderWxid) {
|
||||
candidates.push(senderWxid)
|
||||
}
|
||||
|
||||
// sessionId(1对1聊天时是对方wxid,群聊时是群id)
|
||||
if (sessionId && !candidates.includes(sessionId)) {
|
||||
candidates.push(sessionId)
|
||||
}
|
||||
|
||||
// 我的wxid(兜底)
|
||||
if (myWxid && !candidates.includes(myWxid)) {
|
||||
candidates.push(myWxid)
|
||||
}
|
||||
|
||||
const t3 = Date.now()
|
||||
// 从数据库读取 silk 数据
|
||||
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
|
||||
|
||||
|
||||
// 3. 调用 C++ 接口获取语音 (Hex)
|
||||
const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId)
|
||||
if (!voiceRes.success || !voiceRes.hex) {
|
||||
return { success: false, error: voiceRes.error || '未找到语音数据' }
|
||||
if (!silkData) {
|
||||
return { success: false, error: '未找到语音数据' }
|
||||
}
|
||||
|
||||
const t5 = Date.now()
|
||||
// 使用 silk-wasm 解码
|
||||
const pcmData = await this.decodeSilkToPcm(silkData, 24000)
|
||||
const t6 = Date.now()
|
||||
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
|
||||
|
||||
|
||||
// 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) }
|
||||
if (!pcmData) {
|
||||
return { success: false, error: 'Silk 解码失败' }
|
||||
}
|
||||
|
||||
const t7 = Date.now()
|
||||
// PCM -> WAV
|
||||
const wavData = this.createWavBuffer(pcmData, 24000)
|
||||
const t8 = Date.now()
|
||||
console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`)
|
||||
|
||||
// 缓存 WAV 数据到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
|
||||
// 缓存 WAV 数据到文件(异步,不阻塞返回)
|
||||
this.cacheVoiceWavToFile(cacheKey, wavData)
|
||||
|
||||
console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, data: wavData.toString('base64') }
|
||||
} catch (e) {
|
||||
console.error('ChatService: getVoiceData 失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
@@ -2246,26 +2318,228 @@ class ChatService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查语音是否已有缓存
|
||||
* 缓存 WAV 数据到文件(异步)
|
||||
*/
|
||||
private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise<void> {
|
||||
try {
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
if (!existsSync(voiceCacheDir)) {
|
||||
mkdirSync(voiceCacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
writeFileSync(wavFilePath, wavData)
|
||||
} catch (e) {
|
||||
console.error('[Voice] 缓存文件失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口)
|
||||
* 策略:批量查询 + 多种兜底方案
|
||||
*/
|
||||
private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise<Buffer | null> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const t1 = Date.now()
|
||||
// 获取所有 media 数据库(永久缓存,直到应用重启)
|
||||
let mediaDbFiles: string[]
|
||||
if (this.mediaDbsCache) {
|
||||
mediaDbFiles = this.mediaDbsCache
|
||||
console.log(`[Voice] listMediaDbs (缓存): 0ms`)
|
||||
} else {
|
||||
const mediaDbsResult = await wcdbService.listMediaDbs()
|
||||
const t2 = Date.now()
|
||||
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
|
||||
|
||||
if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
mediaDbFiles = mediaDbsResult.data as string[]
|
||||
this.mediaDbsCache = mediaDbFiles // 永久缓存
|
||||
}
|
||||
|
||||
// 在所有 media 数据库中查找
|
||||
for (const dbPath of mediaDbFiles) {
|
||||
try {
|
||||
// 检查缓存
|
||||
let schema = this.mediaDbSchemaCache.get(dbPath)
|
||||
|
||||
if (!schema) {
|
||||
const t3 = Date.now()
|
||||
// 第一次查询,获取表结构并缓存
|
||||
const tablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
|
||||
)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
|
||||
|
||||
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const voiceTable = tablesResult.rows[0].name
|
||||
|
||||
const t5 = Date.now()
|
||||
const columnsResult = await wcdbService.execQuery('media', dbPath,
|
||||
`PRAGMA table_info('${voiceTable}')`
|
||||
)
|
||||
const t6 = Date.now()
|
||||
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
|
||||
|
||||
if (!columnsResult.success || !columnsResult.rows) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建列名映射(原始名称 -> 小写名称)
|
||||
const columnMap = new Map<string, string>()
|
||||
for (const c of columnsResult.rows) {
|
||||
const name = String(c.name || '')
|
||||
if (name) {
|
||||
columnMap.set(name.toLowerCase(), name)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找数据列(使用原始列名)
|
||||
const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n))
|
||||
const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined
|
||||
|
||||
if (!dataColumn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 查找 chat_name_id 列
|
||||
const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n))
|
||||
const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined
|
||||
|
||||
// 查找时间列
|
||||
const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n))
|
||||
const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined
|
||||
|
||||
const t7 = Date.now()
|
||||
// 查找 Name2Id 表
|
||||
const name2IdTablesResult = await wcdbService.execQuery('media', dbPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'"
|
||||
)
|
||||
const t8 = Date.now()
|
||||
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
|
||||
|
||||
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
|
||||
? name2IdTablesResult.rows[0].name
|
||||
: undefined
|
||||
|
||||
schema = {
|
||||
voiceTable,
|
||||
dataColumn,
|
||||
chatNameIdColumn,
|
||||
timeColumn,
|
||||
name2IdTable
|
||||
}
|
||||
|
||||
// 缓存表结构
|
||||
this.mediaDbSchemaCache.set(dbPath, schema)
|
||||
}
|
||||
|
||||
// 策略1: 通过 chat_name_id + create_time 查找(最准确)
|
||||
if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) {
|
||||
const t9 = Date.now()
|
||||
// 批量获取所有 candidates 的 chat_name_id(减少查询次数)
|
||||
const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',')
|
||||
const name2IdResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})`
|
||||
)
|
||||
const t10 = Date.now()
|
||||
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
|
||||
|
||||
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
|
||||
// 构建 chat_name_id 列表
|
||||
const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid)
|
||||
const chatNameIdsStr = chatNameIds.join(',')
|
||||
|
||||
const t11 = Date.now()
|
||||
// 一次查询所有可能的语音
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t12 = Date.now()
|
||||
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 只通过 create_time 查找(兜底)
|
||||
if (schema.timeColumn) {
|
||||
const t13 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1`
|
||||
)
|
||||
const t14 = Date.now()
|
||||
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况)
|
||||
if (schema.timeColumn) {
|
||||
const t15 = Date.now()
|
||||
const voiceResult = await wcdbService.execQuery('media', dbPath,
|
||||
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1`
|
||||
)
|
||||
const t16 = Date.now()
|
||||
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
|
||||
|
||||
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
|
||||
const row = voiceResult.rows[0]
|
||||
const silkData = this.decodeVoiceBlob(row.data)
|
||||
if (silkData) {
|
||||
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
|
||||
return silkData
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,继续尝试下一个数据库
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查语音是否已有缓存(只检查内存,不查询数据库)
|
||||
*/
|
||||
async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> {
|
||||
try {
|
||||
// 直接用 msgId 生成 cacheKey,不查询数据库
|
||||
// 注意:这里的 cacheKey 可能不准确(因为没有 createTime),但只是用来快速检查缓存
|
||||
// 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId)
|
||||
|
||||
// 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 }
|
||||
@@ -2460,60 +2734,133 @@ class ChatService {
|
||||
async getVoiceTranscript(
|
||||
sessionId: string,
|
||||
msgId: string,
|
||||
createTime?: number,
|
||||
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 startTime = Date.now()
|
||||
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
|
||||
|
||||
const pending = this.voiceTranscriptPending.get(cacheKey)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
try {
|
||||
let msgCreateTime = createTime
|
||||
let serverId: string | number | undefined
|
||||
|
||||
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
|
||||
}
|
||||
// 如果前端没传 createTime,才需要查询消息(这个很慢)
|
||||
if (!msgCreateTime) {
|
||||
const t1 = Date.now()
|
||||
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
|
||||
const t2 = Date.now()
|
||||
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
|
||||
|
||||
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')
|
||||
if (msgResult.success && msgResult.message) {
|
||||
msgCreateTime = msgResult.message.createTime
|
||||
serverId = msgResult.message.serverId
|
||||
console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
|
||||
}
|
||||
|
||||
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
|
||||
if (!msgCreateTime) {
|
||||
console.error(`[Transcribe] 未找到消息时间戳`)
|
||||
return { success: false, error: '未找到消息时间戳' }
|
||||
}
|
||||
|
||||
// 使用正确的 cacheKey(包含 createTime)
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
|
||||
console.log(`[Transcribe] cacheKey=${cacheKey}`)
|
||||
|
||||
// 检查转写缓存
|
||||
const cached = this.voiceTranscriptCache.get(cacheKey)
|
||||
if (cached) {
|
||||
console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`)
|
||||
return { success: true, transcript: cached }
|
||||
}
|
||||
|
||||
// 检查是否正在转写
|
||||
const pending = this.voiceTranscriptPending.get(cacheKey)
|
||||
if (pending) {
|
||||
console.log(`[Transcribe] 正在转写中,等待结果`)
|
||||
return pending
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
// 检查内存中是否有 WAV 数据
|
||||
let wavData = this.voiceWavCache.get(cacheKey)
|
||||
if (wavData) {
|
||||
console.log(`[Transcribe] WAV内存缓存命中,大小: ${wavData.length} bytes`)
|
||||
} else {
|
||||
// 检查文件缓存
|
||||
const voiceCacheDir = this.getVoiceCacheDir()
|
||||
const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`)
|
||||
if (existsSync(wavFilePath)) {
|
||||
try {
|
||||
wavData = readFileSync(wavFilePath)
|
||||
console.log(`[Transcribe] WAV文件缓存命中,大小: ${wavData.length} bytes`)
|
||||
// 同时缓存到内存
|
||||
this.cacheVoiceWav(cacheKey, wavData)
|
||||
} catch (e) {
|
||||
console.error(`[Transcribe] 读取缓存文件失败:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!wavData) {
|
||||
console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`)
|
||||
const t3 = Date.now()
|
||||
// 调用 getVoiceData 获取并解码
|
||||
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId)
|
||||
const t4 = Date.now()
|
||||
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
|
||||
|
||||
if (!voiceResult.success || !voiceResult.data) {
|
||||
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
|
||||
return { success: false, error: voiceResult.error || '语音解码失败' }
|
||||
}
|
||||
wavData = Buffer.from(voiceResult.data, 'base64')
|
||||
console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`)
|
||||
}
|
||||
|
||||
// 转写
|
||||
console.log(`[Transcribe] 开始调用 transcribeWavBuffer`)
|
||||
const t5 = Date.now()
|
||||
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
|
||||
console.log(`[Transcribe] 部分结果: ${text}`)
|
||||
onPartial?.(text)
|
||||
})
|
||||
const t6 = Date.now()
|
||||
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
|
||||
|
||||
if (result.success && result.transcript) {
|
||||
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
|
||||
this.cacheVoiceTranscript(cacheKey, result.transcript)
|
||||
} else {
|
||||
console.error(`[Transcribe] 转写失败: ${result.error}`)
|
||||
}
|
||||
|
||||
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[Transcribe] 异常:`, error)
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.voiceTranscriptPending.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
this.voiceTranscriptPending.set(cacheKey, task)
|
||||
return task
|
||||
} catch (error) {
|
||||
console.error(`[Transcribe] 外层异常:`, error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private getVoiceCacheKey(sessionId: string, msgId: string): string {
|
||||
private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string {
|
||||
// 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱
|
||||
if (createTime) {
|
||||
return `${sessionId}_${createTime}`
|
||||
}
|
||||
return `${sessionId}_${msgId}`
|
||||
}
|
||||
|
||||
@@ -2525,32 +2872,6 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音缓存文件路径
|
||||
*/
|
||||
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) {
|
||||
@@ -2561,8 +2882,6 @@ class ChatService {
|
||||
|
||||
async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> {
|
||||
try {
|
||||
console.info('[ChatService] getMessageById (SQL)', { sessionId, localId })
|
||||
|
||||
// 1. 获取该会话所在的消息表
|
||||
// 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
@@ -2585,7 +2904,6 @@ class ChatService {
|
||||
const message = this.parseMessage(row)
|
||||
|
||||
if (message.localId !== 0) {
|
||||
console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId })
|
||||
return { success: true, message }
|
||||
}
|
||||
}
|
||||
@@ -2628,6 +2946,7 @@ class ChatService {
|
||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
||||
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
|
||||
rawContent: rawContent,
|
||||
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
||||
}
|
||||
|
||||
|
||||
@@ -68,14 +68,7 @@ export class ImageDecryptService {
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
if (meta) {
|
||||
console.info(message, meta)
|
||||
} else {
|
||||
console.info(message)
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
// 只写入文件,不输出到控制台
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
|
||||
256
electron/services/videoService.ts
Normal file
256
electron/services/videoService.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface VideoInfo {
|
||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||
coverUrl?: string // 封面 data URL
|
||||
thumbUrl?: string // 缩略图 data URL
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
private getDbPath(): string {
|
||||
return this.configService.get('dbPath') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的wxid
|
||||
*/
|
||||
private getMyWxid(): string {
|
||||
return this.configService.get('myWxid') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
private getCachePath(): string {
|
||||
return this.configService.get('cachePath') || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 wxid 目录名(去掉后缀)
|
||||
*/
|
||||
private cleanWxid(wxid: string): string {
|
||||
const trimmed = wxid.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const cachePath = this.getCachePath()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
if (!wxid) return undefined
|
||||
|
||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||
if (cachePath) {
|
||||
const cacheDbPaths = [
|
||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, wxid, 'hardlink.db'),
|
||||
join(cachePath, 'hardlink.db'),
|
||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
||||
]
|
||||
|
||||
for (const p of cacheDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
const db = new Database(p, { readonly: true })
|
||||
const row = db.prepare(`
|
||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||
WHERE md5 = ?
|
||||
LIMIT 1
|
||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||
db.close()
|
||||
|
||||
if (row?.file_name) {
|
||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||
return realMd5
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
const encryptedDbPaths = [
|
||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||
]
|
||||
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
const escapedMd5 = md5.replace(/'/g, "''")
|
||||
|
||||
// 用 md5 字段查询,获取 file_name
|
||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||
|
||||
const result = await wcdbService.execQuery('media', p, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
if (row?.file_name) {
|
||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||
return realMd5
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
|
||||
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 遍历年月目录查找视频文件
|
||||
try {
|
||||
const allDirs = readdirSync(videoBaseDir)
|
||||
|
||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
||||
const yearMonthDirs = allDirs
|
||||
.filter(dir => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
return statSync(dirPath).isDirectory()
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
||||
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
|
||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
||||
|
||||
// 检查视频文件是否存在
|
||||
if (existsSync(videoPath)) {
|
||||
return {
|
||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VideoService] Error searching for video:', e)
|
||||
}
|
||||
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
parseVideoMd5(content: string): string | undefined {
|
||||
|
||||
// 打印前500字符看看 XML 结构
|
||||
|
||||
if (!content) return undefined
|
||||
|
||||
try {
|
||||
// 提取所有可能的 md5 值进行日志
|
||||
const allMd5s: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
||||
let match
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5s.push(`${match[0]}`)
|
||||
}
|
||||
|
||||
// 提取 md5(用于查询 hardlink.db)
|
||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
||||
|
||||
// 尝试从videomsg标签中提取md5
|
||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMatch) {
|
||||
return videoMsgMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5Match) {
|
||||
return md5Match[1].toLowerCase()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const videoService = new VideoService()
|
||||
@@ -224,13 +224,16 @@ export class VoiceTranscribeService {
|
||||
let finalTranscript = ''
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
console.log('[VoiceTranscribe] Worker 消息:', msg)
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export class WcdbCore {
|
||||
private writeLog(message: string, force = false): void {
|
||||
if (!force && !this.isLogEnabled()) return
|
||||
const line = `[${new Date().toISOString()}] ${message}`
|
||||
console.log(`[WCDB] ${line}`)
|
||||
// 移除控制台日志,只写入文件
|
||||
try {
|
||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||
const dir = join(base, 'logs')
|
||||
@@ -620,7 +620,7 @@ export class WcdbCore {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, wxid)
|
||||
} catch (e) {
|
||||
console.warn('设置 wxid 失败:', e)
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
if (this.isLogEnabled()) {
|
||||
@@ -799,7 +799,6 @@ export class WcdbCore {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
|
||||
if (Object.keys(resultMap).length > 0) {
|
||||
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
|
||||
}
|
||||
@@ -807,25 +806,18 @@ export class WcdbCore {
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) {
|
||||
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
|
||||
return { success: false, error: '解析头像失败' }
|
||||
}
|
||||
const map = JSON.parse(jsonStr) as Record<string, string>
|
||||
let successCount = 0
|
||||
let emptyCount = 0
|
||||
for (const username of toFetch) {
|
||||
const url = map[username]
|
||||
if (url && url.trim()) {
|
||||
resultMap[username] = url
|
||||
// 只缓存有效的URL
|
||||
this.avatarUrlCache.set(username, { url, updatedAt: now })
|
||||
successCount++
|
||||
} else {
|
||||
emptyCount++
|
||||
// 不缓存空URL,下次可以重新尝试
|
||||
}
|
||||
// 不缓存空URL,下次可以重新尝试
|
||||
}
|
||||
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
|
||||
return { success: true, map: resultMap }
|
||||
} catch (e) {
|
||||
console.error('[wcdbCore] getAvatarUrls 异常:', e)
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
@@ -29,6 +30,7 @@ function App() {
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
|
||||
// 协议同意状态
|
||||
@@ -219,6 +221,11 @@ function App() {
|
||||
return <WelcomePage standalone />
|
||||
}
|
||||
|
||||
// 独立视频播放窗口
|
||||
if (isVideoPlayerWindow) {
|
||||
return <VideoWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
return (
|
||||
<div className="app-container">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.chat-page {
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
@@ -370,9 +370,23 @@
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 65%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 80%;
|
||||
margin-bottom: 4px;
|
||||
align-items: flex-start;
|
||||
|
||||
.bubble-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
min-width: 0; // 允许收缩
|
||||
width: fit-content; // 让气泡宽度由内容决定
|
||||
}
|
||||
|
||||
&.sent {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.bubble-content {
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
@@ -382,6 +396,10 @@
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 2px 10px var(--primary-light);
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&.received {
|
||||
@@ -395,6 +413,10 @@
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&.system {
|
||||
@@ -428,6 +450,11 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
// 防止名字撑开气泡宽度
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
@@ -790,6 +817,99 @@
|
||||
}
|
||||
|
||||
// 右侧消息区域
|
||||
// ... (previous content) ...
|
||||
|
||||
// 链接卡片消息样式
|
||||
.link-message {
|
||||
cursor: pointer;
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
max-width: 300px;
|
||||
margin-top: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.link-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.link-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
|
||||
svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 适配发送出去的消息中的链接卡片
|
||||
.message-bubble.sent .link-message {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
.link-title,
|
||||
.link-desc {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.message-area {
|
||||
flex: 1 1 70%;
|
||||
display: flex;
|
||||
@@ -1485,6 +1605,11 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
// 防止名字撑开气泡宽度
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 引用消息样式
|
||||
@@ -1533,7 +1658,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
min-width: 0; // 允许收缩
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
// 让气泡宽度由内容决定,而不是被父容器撑开
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
@@ -1948,4 +2077,85 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 视频消息样式
|
||||
.video-thumb-wrapper {
|
||||
position: relative;
|
||||
max-width: 300px;
|
||||
min-width: 200px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--bg-tertiary);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
|
||||
.video-play-button {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.9;
|
||||
transition: all 0.2s;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
.video-placeholder,
|
||||
.video-loading,
|
||||
.video-unavailable {
|
||||
min-width: 120px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-loading {
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
@@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const isSystem = isSystemMessage(message.localType)
|
||||
const isEmoji = message.localType === 47
|
||||
const isImage = message.localType === 3
|
||||
const isVideo = message.localType === 43
|
||||
const isVoice = message.localType === 34
|
||||
const isSent = message.isSend === 1
|
||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||
@@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||
const voiceAutoDecryptTriggered = useRef(false)
|
||||
|
||||
// 视频相关状态
|
||||
const [videoLoading, setVideoLoading] = useState(false)
|
||||
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
|
||||
const videoContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||
const [videoMd5, setVideoMd5] = useState<string | null>(null)
|
||||
|
||||
// 解析视频 MD5
|
||||
useEffect(() => {
|
||||
if (!isVideo) return
|
||||
|
||||
console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2))
|
||||
console.log('[Video Debug] Message keys:', Object.keys(message))
|
||||
console.log('[Video Debug] Message:', {
|
||||
localId: message.localId,
|
||||
localType: message.localType,
|
||||
hasVideoMd5: !!message.videoMd5,
|
||||
hasContent: !!message.content,
|
||||
hasParsedContent: !!message.parsedContent,
|
||||
hasRawContent: !!(message as any).rawContent,
|
||||
contentPreview: message.content?.substring(0, 200),
|
||||
parsedContentPreview: message.parsedContent?.substring(0, 200),
|
||||
rawContentPreview: (message as any).rawContent?.substring(0, 200)
|
||||
})
|
||||
|
||||
// 优先使用数据库中的 videoMd5
|
||||
if (message.videoMd5) {
|
||||
console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5)
|
||||
setVideoMd5(message.videoMd5)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试从多个可能的字段获取原始内容
|
||||
const contentToUse = message.content || (message as any).rawContent || message.parsedContent
|
||||
if (contentToUse) {
|
||||
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
|
||||
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => {
|
||||
console.log('[Video Debug] Parse result:', result)
|
||||
if (result && result.success && result.md5) {
|
||||
console.log('[Video Debug] Parsed MD5:', result.md5)
|
||||
setVideoMd5(result.md5)
|
||||
} else {
|
||||
console.error('[Video Debug] Failed to parse MD5:', result)
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[Video Debug] Parse error:', err)
|
||||
})
|
||||
}
|
||||
}, [isVideo, message.videoMd5, message.content, message.parsedContent])
|
||||
|
||||
// 加载自动转文字配置
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
@@ -1784,7 +1835,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId))
|
||||
const result = await window.electronAPI.chat.getVoiceTranscript(
|
||||
session.username,
|
||||
String(message.localId),
|
||||
message.createTime
|
||||
)
|
||||
console.log('[ChatPage] 调用转写:', {
|
||||
sessionId: session.username,
|
||||
msgId: message.localId,
|
||||
createTime: message.createTime
|
||||
})
|
||||
if (result.success) {
|
||||
const transcriptText = (result.transcript || '').trim()
|
||||
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
|
||||
@@ -1829,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
}
|
||||
}, [isVoice, message.localId, requestVoiceTranscript])
|
||||
|
||||
// 视频懒加载
|
||||
useEffect(() => {
|
||||
if (!isVideo || !videoContainerRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVideoVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(videoContainerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [isVideo])
|
||||
|
||||
// 加载视频信息
|
||||
useEffect(() => {
|
||||
if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return
|
||||
if (!videoMd5) {
|
||||
console.log('[Video Debug] No videoMd5 available yet')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Video Debug] Loading video info for MD5:', videoMd5)
|
||||
setVideoLoading(true)
|
||||
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => {
|
||||
console.log('[Video Debug] getVideoInfo result:', result)
|
||||
if (result && result.success) {
|
||||
setVideoInfo({
|
||||
exists: result.exists,
|
||||
videoUrl: result.videoUrl,
|
||||
coverUrl: result.coverUrl,
|
||||
thumbUrl: result.thumbUrl
|
||||
})
|
||||
} else {
|
||||
console.error('[Video Debug] Video info failed:', result)
|
||||
setVideoInfo({ exists: false })
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[Video Debug] getVideoInfo error:', err)
|
||||
setVideoInfo({ exists: false })
|
||||
}).finally(() => {
|
||||
setVideoLoading(false)
|
||||
})
|
||||
}, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5])
|
||||
|
||||
|
||||
// 根据设置决定是否自动转写
|
||||
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
|
||||
|
||||
@@ -1856,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
)
|
||||
}
|
||||
|
||||
// 检测是否为链接卡片消息
|
||||
const isLinkMessage = String(message.localType) === '21474836529' ||
|
||||
(message.rawContent && (message.rawContent.includes('<appmsg') || message.rawContent.includes('<appmsg'))) ||
|
||||
(message.parsedContent && (message.parsedContent.includes('<appmsg') || message.parsedContent.includes('<appmsg')))
|
||||
const bubbleClass = isSent ? 'sent' : 'received'
|
||||
|
||||
// 头像逻辑:
|
||||
@@ -1869,6 +1989,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
? '我'
|
||||
: getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username))
|
||||
|
||||
|
||||
// 是否有引用消息
|
||||
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
||||
|
||||
@@ -1959,6 +2080,72 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
)
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
if (isVideo) {
|
||||
const handlePlayVideo = useCallback(async () => {
|
||||
if (!videoInfo?.videoUrl) return
|
||||
try {
|
||||
await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl)
|
||||
} catch (e) {
|
||||
console.error('打开视频播放窗口失败:', e)
|
||||
}
|
||||
}, [videoInfo?.videoUrl])
|
||||
|
||||
// 未进入可视区域时显示占位符
|
||||
if (!isVideoVisible) {
|
||||
return (
|
||||
<div className="video-placeholder" ref={videoContainerRef}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 加载中
|
||||
if (videoLoading) {
|
||||
return (
|
||||
<div className="video-loading" ref={videoContainerRef}>
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 视频不存在
|
||||
if (!videoInfo?.exists || !videoInfo.videoUrl) {
|
||||
return (
|
||||
<div className="video-unavailable" ref={videoContainerRef}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
<span>视频不可用</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 默认显示缩略图,点击打开独立播放窗口
|
||||
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
|
||||
return (
|
||||
<div className="video-thumb-wrapper" ref={videoContainerRef} onClick={handlePlayVideo}>
|
||||
{thumbSrc ? (
|
||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
|
||||
) : (
|
||||
<div className="video-thumb-placeholder">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="video-play-button">
|
||||
<Play size={32} fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isVoice) {
|
||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||
const handleToggle = async () => {
|
||||
@@ -2157,6 +2344,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 解析引用消息(Links / App Messages)
|
||||
// localType: 21474836529 corresponds to AppMessage which often contains links
|
||||
|
||||
// 带引用的消息
|
||||
if (hasQuote) {
|
||||
return (
|
||||
@@ -2169,6 +2360,68 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 解析引用消息(Links / App Messages)
|
||||
// localType: 21474836529 corresponds to AppMessage which often contains links
|
||||
if (isLinkMessage) {
|
||||
try {
|
||||
// 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置
|
||||
let contentToParse = message.rawContent || message.parsedContent || '';
|
||||
const xmlStartIndex = contentToParse.indexOf('<');
|
||||
if (xmlStartIndex >= 0) {
|
||||
contentToParse = contentToParse.substring(xmlStartIndex);
|
||||
}
|
||||
|
||||
// 处理 HTML 转义字符
|
||||
if (contentToParse.includes('<')) {
|
||||
contentToParse = contentToParse
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(contentToParse, "text/xml");
|
||||
const appMsg = doc.querySelector('appmsg');
|
||||
|
||||
if (appMsg) {
|
||||
const title = doc.querySelector('title')?.textContent || '未命名链接';
|
||||
const des = doc.querySelector('des')?.textContent || '无描述';
|
||||
const url = doc.querySelector('url')?.textContent || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-message"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (url) {
|
||||
// 优先使用 electron 接口打开外部浏览器
|
||||
if (window.electronAPI?.shell?.openExternal) {
|
||||
window.electronAPI.shell.openExternal(url);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="link-header">
|
||||
<div className="link-content">
|
||||
<div className="link-title" title={title}>{title}</div>
|
||||
<div className="link-desc" title={des}>{des}</div>
|
||||
</div>
|
||||
<div className="link-icon">
|
||||
<Link size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse app message', e);
|
||||
}
|
||||
}
|
||||
// 普通消息
|
||||
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
|
||||
}
|
||||
|
||||
216
src/pages/VideoWindow.scss
Normal file
216
src/pages/VideoWindow.scss
Normal file
@@ -0,0 +1,216 @@
|
||||
.video-window-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
.title-bar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
background: #1a1a1a;
|
||||
padding-right: 140px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.window-drag-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
}
|
||||
|
||||
.video-viewport {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
min-height: 0; // 重要:让 flex 子元素可以收缩
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-loading-overlay,
|
||||
.video-error-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.video-error-overlay {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 4;
|
||||
|
||||
svg {
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent);
|
||||
padding: 40px 16px 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
z-index: 6;
|
||||
|
||||
.progress-bar {
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height 0.15s;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #4a9eff);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.time-display {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.volume-slider {
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标悬停时显示控制栏
|
||||
&:hover .video-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 播放时如果鼠标不动,隐藏控制栏
|
||||
&.hide-controls .video-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.video-window-empty {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
199
src/pages/VideoWindow.tsx
Normal file
199
src/pages/VideoWindow.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
|
||||
import './VideoWindow.scss'
|
||||
|
||||
export default function VideoWindow() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const videoPath = searchParams.get('videoPath')
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
//播放/暂停
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}, [isPlaying])
|
||||
|
||||
// 静音切换
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
videoRef.current.muted = !isMuted
|
||||
setIsMuted(!isMuted)
|
||||
}, [isMuted])
|
||||
|
||||
// 进度条点击
|
||||
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressRef.current) return
|
||||
e.stopPropagation()
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
videoRef.current.currentTime = percent * duration
|
||||
}, [duration])
|
||||
|
||||
// 音量调节
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
setVolume(newVolume)
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume
|
||||
setIsMuted(newVolume === 0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 重新播放
|
||||
const handleReplay = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
videoRef.current.currentTime = 0
|
||||
videoRef.current.play()
|
||||
}, [])
|
||||
|
||||
// 快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.electronAPI.window.close()
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
}
|
||||
if (e.key === 'm' || e.key === 'M') toggleMute()
|
||||
if (e.key === 'ArrowLeft' && videoRef.current) {
|
||||
videoRef.current.currentTime -= 5
|
||||
}
|
||||
if (e.key === 'ArrowRight' && videoRef.current) {
|
||||
videoRef.current.currentTime += 5
|
||||
}
|
||||
if (e.key === 'ArrowUp' && videoRef.current) {
|
||||
videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1)
|
||||
setVolume(videoRef.current.volume)
|
||||
}
|
||||
if (e.key === 'ArrowDown' && videoRef.current) {
|
||||
videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1)
|
||||
setVolume(videoRef.current.volume)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [togglePlay, toggleMute])
|
||||
|
||||
if (!videoPath) {
|
||||
return (
|
||||
<div className="video-window-empty">
|
||||
<span>无效的视频路径</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="video-window-container">
|
||||
<div className="title-bar">
|
||||
<div className="window-drag-area"></div>
|
||||
</div>
|
||||
|
||||
<div className="video-viewport" onClick={togglePlay}>
|
||||
{isLoading && (
|
||||
<div className="video-loading-overlay">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="video-error-overlay">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.currentTarget
|
||||
setDuration(video.duration)
|
||||
setIsLoading(false)
|
||||
// 根据视频尺寸调整窗口大小
|
||||
if (video.videoWidth && video.videoHeight) {
|
||||
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onError={() => {
|
||||
setError('视频加载失败')
|
||||
setIsLoading(false)
|
||||
}}
|
||||
onWaiting={() => setIsLoading(true)}
|
||||
onCanPlay={() => setIsLoading(false)}
|
||||
autoPlay
|
||||
/>
|
||||
{!isPlaying && !isLoading && !error && (
|
||||
<div className="play-overlay">
|
||||
<Play size={64} fill="white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="progress-bar"
|
||||
ref={progressRef}
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls-row">
|
||||
<div className="controls-left">
|
||||
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
<button onClick={handleReplay} title="重新播放">
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<span className="time-display">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="controls-right">
|
||||
<div className="volume-control">
|
||||
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
|
||||
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="volume-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/types/electron.d.ts
vendored
19
src/types/electron.d.ts
vendored
@@ -9,6 +9,8 @@ export interface ElectronAPI {
|
||||
completeOnboarding: () => Promise<boolean>
|
||||
openOnboardingWindow: () => Promise<boolean>
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
}
|
||||
config: {
|
||||
get: (key: string) => Promise<unknown>
|
||||
@@ -96,7 +98,7 @@ export interface ElectronAPI {
|
||||
getImageData: (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 }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
}
|
||||
|
||||
@@ -107,6 +109,21 @@ export interface ElectronAPI {
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
}
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => Promise<{
|
||||
success: boolean
|
||||
exists: boolean
|
||||
videoUrl?: string
|
||||
coverUrl?: string
|
||||
thumbUrl?: string
|
||||
error?: string
|
||||
}>
|
||||
parseVideoMd5: (content: string) => Promise<{
|
||||
success: boolean
|
||||
md5?: string
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
analytics: {
|
||||
getOverallStatistics: (force?: boolean) => Promise<{
|
||||
success: boolean
|
||||
|
||||
@@ -33,11 +33,14 @@ export interface Message {
|
||||
isSend: number | null
|
||||
senderUsername: string | null
|
||||
parsedContent: string
|
||||
rawContent?: string // 原始消息内容(保留用于兼容)
|
||||
content?: string // 原始消息内容(XML)
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
emojiCdnUrl?: string
|
||||
emojiMd5?: string
|
||||
voiceDurationSeconds?: number
|
||||
videoMd5?: string
|
||||
// 引用消息
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
|
||||
Reference in New Issue
Block a user