计划优化 P2/5

This commit is contained in:
cc
2026-03-19 21:24:31 +08:00
parent 35e9ea13de
commit 48e5ce807d
9 changed files with 1207 additions and 506 deletions

View File

@@ -242,12 +242,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
ipcRenderer.invoke('image:preload', payloads),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
},
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
}
},

View File

@@ -2332,7 +2332,8 @@ class ExportService {
sessionId,
imageMd5,
imageDatName,
force: true // 导出优先高清,失败再回退缩略图
force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true
})
if (!result.success || !result.localPath) {
@@ -2341,7 +2342,8 @@ class ExportService {
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName
imageDatName,
preferFilePath: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
@@ -2404,6 +2406,55 @@ class ExportService {
}
}
private async preloadMediaLookupCaches(
_sessionId: string,
messages: any[],
options: { exportImages?: boolean; exportVideos?: boolean },
control?: ExportTaskControl
): Promise<void> {
if (!Array.isArray(messages) || messages.length === 0) return
const md5Pattern = /^[a-f0-9]{32}$/i
const imageMd5Set = new Set<string>()
const videoMd5Set = new Set<string>()
let scanIndex = 0
for (const msg of messages) {
if ((scanIndex++ & 0x7f) === 0) {
this.throwIfStopRequested(control)
}
if (options.exportImages && msg?.localType === 3) {
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
} else {
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
if (md5Pattern.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
}
if (options.exportVideos && msg?.localType === 43) {
const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase()
if (videoMd5) videoMd5Set.add(videoMd5)
}
}
const preloadTasks: Array<Promise<void>> = []
if (imageMd5Set.size > 0) {
preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set)))
}
if (videoMd5Set.size > 0) {
preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set)))
}
if (preloadTasks.length === 0) return
await Promise.all(preloadTasks.map((task) => task.catch(() => { })))
this.throwIfStopRequested(control)
}
/**
* 导出语音文件
*/
@@ -3644,6 +3695,10 @@ class ExportService {
const mediaDirCache = new Set<string>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
@@ -4127,6 +4182,10 @@ class ExportService {
const mediaDirCache = new Set<string>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
@@ -4934,6 +4993,10 @@ class ExportService {
const mediaDirCache = new Set<string>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
@@ -5600,6 +5663,10 @@ class ExportService {
const mediaDirCache = new Set<string>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
@@ -5938,6 +6005,10 @@ class ExportService {
const mediaDirCache = new Set<string>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)
@@ -6344,6 +6415,10 @@ class ExportService {
const mediaCache = new Map<string, MediaExportItem | null>()
if (mediaMessages.length > 0) {
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
exportImages: options.exportImages,
exportVideos: options.exportVideos
}, control)
const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34)
if (voiceMediaMessages.length > 0) {
await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control)

View File

@@ -55,6 +55,17 @@ type DecryptResult = {
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
type CachedImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
preferFilePath?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
}
export class ImageDecryptService {
private configService = new ConfigService()
private resolvedCache = new Map<string, string>()
@@ -100,7 +111,7 @@ export class ImageDecryptService {
}
}
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed()
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
@@ -110,7 +121,7 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
@@ -118,8 +129,8 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
@@ -130,7 +141,7 @@ export class ImageDecryptService {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
@@ -138,15 +149,15 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' }
}
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
await this.ensureCacheIndexed()
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
@@ -160,9 +171,8 @@ export class ImageDecryptService {
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
@@ -175,9 +185,8 @@ export class ImageDecryptService {
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const dataUrl = this.fileToDataUrl(existingHd)
const localPath = dataUrl || this.filePathToUrl(existingHd)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
}
}
@@ -185,9 +194,8 @@ export class ImageDecryptService {
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
@@ -207,8 +215,44 @@ export class ImageDecryptService {
}
}
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
if (normalizedList.length === 0) return
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return
try {
const ready = await this.ensureWcdbReady()
if (!ready) return
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
const result = await wcdbService.resolveImageHardlinkBatch(requests)
if (!result.success || !Array.isArray(result.rows)) return
for (const row of result.rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5) continue
const fullPath = String(row?.data?.full_path || '').trim()
if (!fullPath || !existsSync(fullPath)) continue
this.cacheDatPath(accountDir, md5, fullPath)
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
if (fileName) {
this.cacheDatPath(accountDir, fileName, fullPath)
}
}
} catch {
// ignore preload failures
}
}
private async decryptImageInternal(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
payload: DecryptImagePayload,
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
@@ -248,10 +292,9 @@ export class ImageDecryptService {
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath)
const localPath = dataUrl || this.filePathToUrl(datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, localPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
@@ -263,10 +306,9 @@ export class ImageDecryptService {
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, localPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
}
@@ -326,9 +368,11 @@ export class ImageDecryptService {
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
}
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = payload.preferFilePath
? outputPath
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, emitPath)
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -1494,6 +1538,16 @@ export class ImageDecryptService {
return `data:${mimeType};base64,${buffer.toString('base64')}`
}
private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return filePath
return this.resolveEmitPath(filePath, false)
}
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return this.filePathToUrl(filePath)
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
}
private fileToDataUrl(filePath: string): string | null {
try {
const ext = extname(filePath).toLowerCase()

View File

@@ -11,8 +11,28 @@ export interface VideoInfo {
exists: boolean
}
interface TimedCacheEntry<T> {
value: T
expiresAt: number
}
interface VideoIndexEntry {
videoPath?: string
coverPath?: string
thumbPath?: string
}
class VideoService {
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
constructor() {
this.configService = new ConfigService()
@@ -28,6 +48,40 @@ class VideoService {
} catch { }
}
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
const hit = cache.get(key)
if (!hit) return undefined
if (hit.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return hit.value
}
private writeTimedCache<T>(
cache: Map<string, TimedCacheEntry<T>>,
key: string,
value: T,
ttlMs: number,
maxEntries: number
): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
if (cache.size <= maxEntries) return
const now = Date.now()
for (const [cacheKey, entry] of cache) {
if (entry.expiresAt <= now) {
cache.delete(cacheKey)
}
}
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value as string | undefined
if (!oldestKey) break
cache.delete(oldestKey)
}
}
/**
* 获取数据库根目录
*/
@@ -42,13 +96,6 @@ class VideoService {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.getCacheBasePath()
}
/**
* 清理 wxid 目录名(去掉后缀)
*/
@@ -68,66 +115,152 @@ class VideoService {
return trimmed
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
private getScopeKey(dbPath: string, wxid: string): string {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
return join(dbPath, wxid, 'msg', 'video')
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
this.log('尝试加密 hardlink.db', { path: p })
const result = await wcdbService.resolveVideoHardlinkMd5(md5, p)
if (result.success && result.data?.resolved_md5) {
const realMd5 = String(result.data.resolved_md5)
this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 })
return realMd5
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
}
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) {
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async resolveVideoHardlinks(
md5List: string[],
dbPath: string,
wxid: string,
cleanedWxid: string
): Promise<Map<string, string>> {
const scopeKey = this.getScopeKey(dbPath, wxid)
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
const resolvedMap = new Map<string, string>()
let unresolved = [...normalizedList]
for (const md5 of normalizedList) {
const cacheKey = `${scopeKey}|${md5}`
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
if (cached === undefined) continue
if (cached) resolvedMap.set(md5, cached)
unresolved = unresolved.filter((item) => item !== md5)
}
if (unresolved.length === 0) return resolvedMap
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
for (const p of encryptedDbPaths) {
if (!existsSync(p) || unresolved.length === 0) continue
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
try {
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
if (batchResult.success && Array.isArray(batchResult.rows)) {
for (const row of batchResult.rows) {
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
const inputMd5 = index >= 0 && index < requests.length
? requests[index].md5
: String(row?.md5 || '').trim().toLowerCase()
if (!inputMd5) continue
const resolvedMd5 = row?.success && row?.data?.resolved_md5
? String(row.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${inputMd5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(inputMd5, resolvedMd5)
}
} else {
this.log('加密 hardlink.db 不存在', { path: p })
// 兼容不支持批量接口的版本,回退单条请求。
for (const req of requests) {
try {
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
const resolvedMd5 = single.success && single.data?.resolved_md5
? String(single.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${req.md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(req.md5, resolvedMd5)
} catch { }
}
}
} catch (e) {
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
}
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
unresolved = unresolved.filter((md5) => !resolvedMap.has(md5))
}
for (const md5 of unresolved) {
const cacheKey = `${scopeKey}|${md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
}
return resolvedMap
}
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
if (!normalizedMd5 || !wxid || !dbPath) {
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
return undefined
}
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
const resolved = resolvedMap.get(normalizedMd5)
if (resolved) {
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
return resolved
}
return undefined
}
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!dbPath || !wxid) return
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
try {
if (!existsSync(filePath)) return undefined
if (!filePath || !existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
@@ -135,118 +268,214 @@ class VideoService {
}
}
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
if (cached) return cached
const index = new Map<string, VideoIndexEntry>()
const ensureEntry = (key: string): VideoIndexEntry => {
let entry = index.get(key)
if (!entry) {
entry = {}
index.set(key, entry)
}
return entry
}
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
let files: string[] = []
try {
files = readdirSync(dirPath)
} catch {
continue
}
for (const file of files) {
const lower = file.toLowerCase()
const fullPath = join(dirPath, file)
if (lower.endsWith('.mp4')) {
const md5 = lower.slice(0, -4)
const entry = ensureEntry(md5)
if (!entry.videoPath) entry.videoPath = fullPath
if (md5.endsWith('_raw')) {
const baseMd5 = md5.replace(/_raw$/, '')
const baseEntry = ensureEntry(baseMd5)
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
}
continue
}
if (!lower.endsWith('.jpg')) continue
const jpgBase = lower.slice(0, -4)
if (jpgBase.endsWith('_thumb')) {
const baseMd5 = jpgBase.slice(0, -6)
const entry = ensureEntry(baseMd5)
if (!entry.thumbPath) entry.thumbPath = fullPath
} else {
const entry = ensureEntry(jpgBase)
if (!entry.coverPath) entry.coverPath = fullPath
}
}
}
for (const [key, entry] of index) {
if (!key.endsWith('_raw')) continue
const baseKey = key.replace(/_raw$/, '')
const baseEntry = index.get(baseKey)
if (!baseEntry) continue
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
}
} catch (e) {
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
}
this.writeTimedCache(
this.videoDirIndexCache,
videoBaseDir,
index,
this.videoIndexCacheTtlMs,
this.maxIndexEntries
)
return index
}
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
const candidates = [normalizedMd5]
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
if (baseMd5 !== normalizedMd5) {
candidates.push(baseMd5)
} else {
candidates.push(`${normalizedMd5}_raw`)
}
for (const key of candidates) {
const entry = index.get(key)
if (!entry?.videoPath) continue
if (!existsSync(entry.videoPath)) continue
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
exists: true
}
}
return null
}
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (!existsSync(videoPath)) continue
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
} catch (e) {
this.log('fallback 扫描视频目录失败', { error: String(e) })
}
return null
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
if (!dbPath || !wxid || !normalizedMd5) {
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}`
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
const pending = this.pendingVideoInfo.get(cacheKey)
if (pending) return pending
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false }
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return miss
}
// 遍历年月目录查找视频文件
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5)
if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return indexed
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5)
if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return fallback
}
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
return miss
})()
this.pendingVideoInfo.set(cacheKey, task)
try {
const allDirs = readdirSync(videoBaseDir)
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a))
this.log('扫描目录', { dirs: yearMonthDirs })
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (existsSync(videoPath)) {
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
const allFiles = readdirSync(dirPath)
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
this.log('找到视频,相关文件列表', {
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath),
relatedFiles,
coverPath,
thumbPath
})
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
return await task
} finally {
this.pendingVideoInfo.delete(cacheKey)
}
}
}
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
this.log('未找到视频,开始全目录扫描', {
lookingForOriginal: `${videoMd5}.mp4`,
lookingForResolved: `${realVideoMd5}.mp4`,
hardlinkResolved: realVideoMd5 !== videoMd5
})
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
try {
const allFiles = readdirSync(dirPath)
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
// 检查原始 md5 是否部分匹配前8位
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
this.log(`目录 ${yearMonth} 扫描结果`, {
totalFiles: allFiles.length,
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
sampleMp4: mp4Files,
partialMatchByOriginalMd5: partialMatch
})
} catch (e) {
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
}
}
} catch (e) {
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
}
/**
* 根据消息内容解析视频MD5

View File

@@ -108,6 +108,9 @@ export class WcdbCore {
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private imageHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null
@@ -1281,6 +1284,52 @@ export class WcdbCore {
return { begin: normalizedBegin, end: normalizedEnd }
}
private makeHardlinkCacheKey(primary: string, secondary?: string | null): string {
const a = String(primary || '').trim().toLowerCase()
const b = String(secondary || '').trim().toLowerCase()
return `${a}\u001f${b}`
}
private readHardlinkCache(
cache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }>,
key: string
): { success: boolean; data?: any; error?: string } | null {
const entry = cache.get(key)
if (!entry) return null
if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) {
cache.delete(key)
return null
}
return this.cloneHardlinkResult(entry.result)
}
private writeHardlinkCache(
cache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }>,
key: string,
result: { success: boolean; data?: any; error?: string }
): void {
cache.set(key, {
result: this.cloneHardlinkResult(result),
updatedAt: Date.now()
})
}
private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } {
const data = result.data && typeof result.data === 'object'
? { ...result.data }
: result.data
return {
success: result.success === true,
data,
error: result.error
}
}
private clearHardlinkCaches(): void {
this.imageHardlinkCache.clear()
this.videoHardlinkCache.clear()
}
isReady(): boolean {
return this.ensureReady()
}
@@ -1388,6 +1437,7 @@ export class WcdbCore {
this.currentWxid = null
this.currentDbStoragePath = null
this.initialized = false
this.clearHardlinkCaches()
this.stopLogPolling()
}
}
@@ -2751,13 +2801,22 @@ export class WcdbCore {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' }
try {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const normalizedAccountDir = String(accountDir || '').trim()
if (!normalizedMd5) return { success: false, error: 'md5 为空' }
const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir)
const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey)
if (cached) return cached
const outPtr = [null as any]
const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr)
const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
const finalResult = { success: true, data }
this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult)
return finalResult
} catch (e) {
return { success: false, error: String(e) }
}
@@ -2767,13 +2826,80 @@ export class WcdbCore {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' }
try {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const normalizedDbPath = String(dbPath || '').trim()
if (!normalizedMd5) return { success: false, error: 'md5 为空' }
const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath)
const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey)
if (cached) return cached
const outPtr = [null as any]
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr)
const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' }
const data = JSON.parse(jsonStr) || {}
return { success: true, data }
const finalResult = { success: true, data }
this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult)
return finalResult
} catch (e) {
return { success: false, error: String(e) }
}
}
async resolveImageHardlinkBatch(
requests: Array<{ md5: string; accountDir?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
try {
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
for (let i = 0; i < requests.length; i += 1) {
const req = requests[i] || { md5: '' }
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
if (!normalizedMd5) {
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
continue
}
const result = await this.resolveImageHardlink(normalizedMd5, req.accountDir)
rows.push({
index: i,
md5: normalizedMd5,
success: result.success === true,
data: result.data,
error: result.error
})
}
return { success: true, rows }
} catch (e) {
return { success: false, error: String(e) }
}
}
async resolveVideoHardlinkMd5Batch(
requests: Array<{ md5: string; dbPath?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' }
try {
const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = []
for (let i = 0; i < requests.length; i += 1) {
const req = requests[i] || { md5: '' }
const normalizedMd5 = String(req.md5 || '').trim().toLowerCase()
if (!normalizedMd5) {
rows.push({ index: i, md5: '', success: false, error: 'md5 为空' })
continue
}
const result = await this.resolveVideoHardlinkMd5(normalizedMd5, req.dbPath)
rows.push({
index: i,
md5: normalizedMd5,
success: result.success === true,
data: result.data,
error: result.error
})
}
return { success: true, rows }
} catch (e) {
return { success: false, error: String(e) }
}

View File

@@ -505,10 +505,22 @@ export class WcdbService {
return this.callWorker('resolveImageHardlink', { md5, accountDir })
}
async resolveImageHardlinkBatch(
requests: Array<{ md5: string; accountDir?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveImageHardlinkBatch', { requests })
}
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
}
async resolveVideoHardlinkMd5Batch(
requests: Array<{ md5: string; dbPath?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
}
/**
* 获取朋友圈
*/

View File

@@ -200,9 +200,15 @@ if (parentPort) {
case 'resolveImageHardlink':
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
break
case 'resolveImageHardlinkBatch':
result = await core.resolveImageHardlinkBatch(payload.requests)
break
case 'resolveVideoHardlinkMd5':
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
break
case 'resolveVideoHardlinkMd5Batch':
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
break
case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break

View File

@@ -1777,6 +1777,10 @@
}
}
.message-virtuoso {
width: 100%;
}
.loading-messages.loading-overlay {
position: absolute;
inset: 0;
@@ -1894,6 +1898,7 @@
.message-wrapper {
display: flex;
flex-direction: column;
margin-bottom: 16px;
-webkit-app-region: no-drag;
&.sent {

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { useShallow } from 'zustand/react/shallow'
import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
@@ -774,7 +776,6 @@ function ChatPage(props: ChatPageProps) {
isConnecting,
connectionError,
sessions,
filteredSessions,
currentSessionId,
isLoadingSessions,
messages,
@@ -786,7 +787,6 @@ function ChatPage(props: ChatPageProps) {
setConnecting,
setConnectionError,
setSessions,
setFilteredSessions,
setCurrentSession,
setLoadingSessions,
setMessages,
@@ -797,11 +797,46 @@ function ChatPage(props: ChatPageProps) {
hasMoreLater,
setHasMoreLater,
setSearchKeyword
} = useChatStore()
} = useChatStore(useShallow((state) => ({
isConnected: state.isConnected,
isConnecting: state.isConnecting,
connectionError: state.connectionError,
sessions: state.sessions,
currentSessionId: state.currentSessionId,
isLoadingSessions: state.isLoadingSessions,
messages: state.messages,
isLoadingMessages: state.isLoadingMessages,
isLoadingMore: state.isLoadingMore,
hasMoreMessages: state.hasMoreMessages,
searchKeyword: state.searchKeyword,
setConnected: state.setConnected,
setConnecting: state.setConnecting,
setConnectionError: state.setConnectionError,
setSessions: state.setSessions,
setCurrentSession: state.setCurrentSession,
setLoadingSessions: state.setLoadingSessions,
setMessages: state.setMessages,
appendMessages: state.appendMessages,
setLoadingMessages: state.setLoadingMessages,
setLoadingMore: state.setLoadingMore,
setHasMoreMessages: state.setHasMoreMessages,
hasMoreLater: state.hasMoreLater,
setHasMoreLater: state.setHasMoreLater,
setSearchKeyword: state.setSearchKeyword
})))
const messageListRef = useRef<HTMLDivElement>(null)
const [messageListScrollParent, setMessageListScrollParent] = useState<HTMLDivElement | null>(null)
const messageVirtuosoRef = useRef<VirtuosoHandle | null>(null)
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
const topRangeLoadLockRef = useRef(false)
const bottomRangeLoadLockRef = useRef(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
messageListRef.current = node
setMessageListScrollParent(node)
}, [])
const getMessageKey = useCallback((msg: Message): string => {
if (msg.messageKey) return msg.messageKey
@@ -857,6 +892,7 @@ function ChatPage(props: ChatPageProps) {
)
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false)
@@ -877,8 +913,36 @@ function ChatPage(props: ChatPageProps) {
const [tempFields, setTempFields] = useState<XmlField[]>([])
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
const {
isBatchTranscribing,
batchTranscribeProgress,
startTranscribe,
updateProgress,
finishTranscribe,
setShowBatchProgress
} = useBatchTranscribeStore(useShallow((state) => ({
isBatchTranscribing: state.isBatchTranscribing,
batchTranscribeProgress: state.progress,
startTranscribe: state.startTranscribe,
updateProgress: state.updateProgress,
finishTranscribe: state.finishTranscribe,
setShowBatchProgress: state.setShowToast
})))
const {
isBatchDecrypting,
batchDecryptProgress,
startDecrypt,
updateDecryptProgress,
finishDecrypt,
setShowBatchDecryptToast
} = useBatchImageDecryptStore(useShallow((state) => ({
isBatchDecrypting: state.isBatchDecrypting,
batchDecryptProgress: state.progress,
startDecrypt: state.startDecrypt,
updateDecryptProgress: state.updateProgress,
finishDecrypt: state.finishDecrypt,
setShowBatchDecryptToast: state.setShowToast
})))
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
@@ -943,8 +1007,6 @@ function ChatPage(props: ChatPageProps) {
const sessionSwitchRequestSeqRef = useRef(0)
const initialLoadRequestedSessionRef = useRef<string | null>(null)
const prevSessionRef = useRef<string | null>(null)
const isLoadingMessagesRef = useRef(false)
const isLoadingMoreRef = useRef(false)
const isConnectedRef = useRef(false)
const isRefreshingRef = useRef(false)
const searchKeywordRef = useRef('')
@@ -2224,7 +2286,6 @@ function ChatPage(props: ChatPageProps) {
setIsLoadingGroupMembers(false)
setCurrentSession(null)
setSessions([])
setFilteredSessions([])
setMessages([])
setSearchKeyword('')
setConnectionError(null)
@@ -2243,7 +2304,6 @@ function ChatPage(props: ChatPageProps) {
setConnecting,
setConnectionError,
setCurrentSession,
setFilteredSessions,
setHasMoreLater,
setHasMoreMessages,
setMessages,
@@ -2254,6 +2314,24 @@ function ChatPage(props: ChatPageProps) {
setSessions
])
useEffect(() => {
let canceled = false
void configService.getAutoTranscribeVoice()
.then((enabled) => {
if (!canceled) {
setAutoTranscribeVoiceEnabled(Boolean(enabled))
}
})
.catch(() => {
if (!canceled) {
setAutoTranscribeVoiceEnabled(false)
}
})
return () => {
canceled = true
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
@@ -2270,6 +2348,8 @@ function ChatPage(props: ChatPageProps) {
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
topRangeLoadLockRef.current = false
bottomRangeLoadLockRef.current = false
}, [currentSessionId])
const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => {
@@ -2611,7 +2691,11 @@ function ChatPage(props: ChatPageProps) {
flashNewMessages(newOnes.map(getMessageKey))
// 滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
const latestMessages = useChatStore.getState().messages || []
const lastIndex = latestMessages.length - 1
if (lastIndex >= 0 && messageVirtuosoRef.current) {
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
} else if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
@@ -2660,7 +2744,11 @@ function ChatPage(props: ChatPageProps) {
flashNewMessages(newMessages.map(getMessageKey))
// 滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
const currentMessages = useChatStore.getState().messages || []
const lastIndex = currentMessages.length - 1
if (lastIndex >= 0 && messageVirtuosoRef.current) {
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
} else if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
@@ -2739,7 +2827,16 @@ function ChatPage(props: ChatPageProps) {
setLoadingMore(true)
}
// 记录加载前的第一条消息元素
const visibleRange = visibleMessageRangeRef.current
const visibleStartIndex = Math.min(
Math.max(visibleRange.startIndex, 0),
Math.max(messages.length - 1, 0)
)
const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0
? getMessageKey(messages[visibleStartIndex])
: null
// 记录加载前的第一条消息元素(非虚拟列表回退路径)
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try {
@@ -2792,13 +2889,21 @@ function ChatPage(props: ChatPageProps) {
// 日期跳转时滚动到顶部,否则滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
if (isDateJumpRef.current) {
if (messageVirtuosoRef.current && result.messages.length > 0) {
messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' })
} else if (messageListRef.current) {
messageListRef.current.scrollTop = 0
isDateJumpRef.current = false
} else {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
isDateJumpRef.current = false
return
}
const lastIndex = result.messages.length - 1
if (lastIndex >= 0 && messageVirtuosoRef.current) {
messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' })
} else if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
} else {
@@ -2816,13 +2921,28 @@ function ChatPage(props: ChatPageProps) {
}
}
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
if (firstMsgEl && listEl) {
// 加载更消息保持视口锚点,避免跳屏
requestAnimationFrame(() => {
listEl.scrollTop = firstMsgEl.offsetTop - 80
})
if (messageVirtuosoRef.current) {
if (anchorMessageKeyBeforePrepend) {
const latestMessages = useChatStore.getState().messages || []
const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend)
if (anchorIndex >= 0) {
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
return
}
}
if (result.messages.length > 0) {
messageVirtuosoRef.current.scrollToIndex({ index: result.messages.length, align: 'start', behavior: 'auto' })
}
return
}
if (firstMsgEl && listEl) {
listEl.scrollTop = firstMsgEl.offsetTop - 80
}
})
}
// 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的
if (ascending) {
setHasMoreMessages(false)
@@ -3775,43 +3895,66 @@ function ChatPage(props: ChatPageProps) {
setGlobalMsgAuthoritativeSessionCount(0)
}, [])
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
const scrollTimeoutRef = useRef<number | null>(null)
const handleScroll = useCallback(() => {
if (!messageListRef.current) return
// 节流:延迟执行,避免滚动时频繁计算
if (scrollTimeoutRef.current) {
cancelAnimationFrame(scrollTimeoutRef.current)
const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
visibleMessageRangeRef.current = range
const total = messages.length
if (total <= 0) {
setShowScrollToBottom(prev => (prev ? false : prev))
return
}
scrollTimeoutRef.current = requestAnimationFrame(() => {
if (!messageListRef.current) return
const remaining = (total - 1) - range.endIndex
const shouldShowScrollButton = remaining > 3
setShowScrollToBottom(prev => (prev === shouldShowScrollButton ? prev : shouldShowScrollButton))
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
// 显示回到底部按钮:距离底部超过 300px
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollToBottom(distanceFromBottom > 300)
// 预加载:当滚动到顶部 30% 区域时开始加载
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
const threshold = clientHeight * 0.3
if (scrollTop < threshold) {
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
}
if (
range.startIndex <= 2 &&
!topRangeLoadLockRef.current &&
!isLoadingMore &&
!isLoadingMessages &&
hasMoreMessages &&
currentSessionId
) {
topRangeLoadLockRef.current = true
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
}
// 预加载更晚的消息
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
const threshold = clientHeight * 0.3
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
if (distanceFromBottom < threshold) {
loadLaterMessages()
if (
range.endIndex >= total - 3 &&
!bottomRangeLoadLockRef.current &&
!isLoadingMore &&
!isLoadingMessages &&
hasMoreLater &&
currentSessionId
) {
bottomRangeLoadLockRef.current = true
void loadLaterMessages()
}
}, [
messages.length,
isLoadingMore,
isLoadingMessages,
hasMoreMessages,
hasMoreLater,
currentSessionId,
currentOffset,
jumpStartTime,
jumpEndTime,
loadMessages,
loadLaterMessages
])
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
if (!atBottom) {
bottomRangeLoadLockRef.current = false
}
})
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
}, [])
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
if (!atTop) {
topRangeLoadLockRef.current = false
}
}, [])
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
@@ -3885,13 +4028,22 @@ function ChatPage(props: ChatPageProps) {
// 滚动到底部
const scrollToBottom = useCallback(() => {
const lastIndex = messages.length - 1
if (lastIndex >= 0 && messageVirtuosoRef.current) {
messageVirtuosoRef.current.scrollToIndex({
index: lastIndex,
align: 'end',
behavior: 'smooth'
})
return
}
if (messageListRef.current) {
messageListRef.current.scrollTo({
top: messageListRef.current.scrollHeight,
behavior: 'smooth'
})
}
}, [])
}, [messages.length])
// 拖动调节侧边栏宽度
const handleResizeStart = useCallback((e: React.MouseEvent) => {
@@ -4021,9 +4173,11 @@ function ChatPage(props: ChatPageProps) {
}, [sessions])
useEffect(() => {
isLoadingMessagesRef.current = isLoadingMessages
isLoadingMoreRef.current = isLoadingMore
}, [isLoadingMessages, isLoadingMore])
if (!isLoadingMore) {
topRangeLoadLockRef.current = false
bottomRangeLoadLockRef.current = false
}
}, [isLoadingMore])
useEffect(() => {
if (initialRevealTimerRef.current !== null) {
@@ -4195,17 +4349,16 @@ function ChatPage(props: ChatPageProps) {
}, [sessions, persistSessionListCache])
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
useEffect(() => {
const filteredSessions = useMemo(() => {
if (!Array.isArray(sessions)) {
setFilteredSessions([])
return
return []
}
// 检查是否有折叠的群聊
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
const hasFoldedGroups = foldedGroups.length > 0
let visible = sessions.filter(s => {
const visible = sessions.filter(s => {
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
return true
})
@@ -4246,11 +4399,10 @@ function ChatPage(props: ChatPageProps) {
}
if (!searchKeyword.trim()) {
setFilteredSessions(visible)
return
return visible
}
const lower = searchKeyword.toLowerCase()
setFilteredSessions(visible
return visible
.filter(s => {
const matchedByName = s.displayName?.toLowerCase().includes(lower)
const matchedByUsername = s.username.toLowerCase().includes(lower)
@@ -4272,11 +4424,9 @@ function ChatPage(props: ChatPageProps) {
matchedField = 'name'
}
// ✅ 关键点:返回一个新对象,解耦全局状态
return { ...s, matchedField }
})
)
}, [sessions, searchKeyword, setFilteredSessions])
}, [sessions, searchKeyword])
// 折叠群列表(独立计算,供折叠 panel 使用)
const foldedSessions = useMemo(() => {
@@ -4314,6 +4464,25 @@ function ChatPage(props: ChatPageProps) {
})
}, [sessions, searchKeyword, foldedView])
const sessionLookupMap = useMemo(() => {
const map = new Map<string, ChatSession>()
for (const session of sessions) {
const username = String(session.username || '').trim()
if (!username) continue
map.set(username, session)
}
return map
}, [sessions])
const groupedGlobalMsgResults = useMemo(() => {
const grouped = globalMsgResults.reduce((acc, msg) => {
const sessionId = (msg as any).sessionId || '未知'
if (!acc[sessionId]) acc[sessionId] = []
acc[sessionId].push(msg)
return acc
}, {} as Record<string, Message[]>)
return Object.entries(grouped)
}, [globalMsgResults])
const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0
const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords
const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords
@@ -5080,6 +5249,85 @@ function ChatPage(props: ChatPageProps) {
}
}
const messageVirtuosoComponents = useMemo(() => ({
Header: () => (
hasMoreMessages ? (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
) : null
),
Footer: () => (
hasMoreLater ? (
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
) : null
)
}), [hasMoreMessages, hasMoreLater, isLoadingMore])
const renderMessageListItem = useCallback((index: number, msg: Message) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
const isSent = msg.isSend === 1
const isSystem = isSystemMessage(msg.localType)
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MemoMessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isCurrentSessionGroup}
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode}
messageKey={messageKey}
isSelected={selectedMessages.has(messageKey)}
onToggleSelection={handleToggleSelection}
/>
</div>
)
}, [
messages,
highlightedMessageSet,
getMessageKey,
formatDateDivider,
currentSession,
myAvatarUrl,
isCurrentSessionGroup,
autoTranscribeVoiceEnabled,
handleRequireModelDownload,
handleContextMenu,
isSelectionMode,
selectedMessages,
handleToggleSelection
])
return (
<div className={`chat-page ${isResizing ? 'resizing' : ''} ${standaloneSessionWindow ? 'standalone session-only' : ''}`}>
{/* 自定义删除确认对话框 */}
@@ -5232,17 +5480,10 @@ function ChatPage(props: ChatPageProps) {
)}
</div>
<div className="search-results-list">
{Object.entries(
globalMsgResults.reduce((acc, msg) => {
const sessionId = (msg as any).sessionId || '未知';
if (!acc[sessionId]) acc[sessionId] = [];
acc[sessionId].push(msg);
return acc;
}, {} as Record<string, Message[]>)
).map(([sessionId, messages]) => {
const session = sessions.find(s => s.username === sessionId);
const firstMsg = messages[0];
const count = messages.length;
{groupedGlobalMsgResults.map(([sessionId, messages]) => {
const session = sessionLookupMap.get(sessionId)
const firstMsg = messages[0]
const count = messages.length
return (
<div
key={sessionId}
@@ -5276,7 +5517,7 @@ function ChatPage(props: ChatPageProps) {
)}
</div>
</div>
);
)
})}
</div>
</>
@@ -5661,77 +5902,27 @@ function ChatPage(props: ChatPageProps) {
)}
<div
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
ref={messageListRef}
onScroll={handleScroll}
ref={handleMessageListScrollParentRef}
>
{hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
)}
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
<div className="empty-chat-inline">
<MessageSquare size={32} />
<span></span>
</div>
)}
{(messages || []).map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
const isSent = msg.isSend === 1
const isSystem = isSystemMessage(msg.localType)
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isCurrentSessionGroup}
onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode}
messageKey={messageKey}
isSelected={selectedMessages.has(messageKey)}
onToggleSelection={handleToggleSelection}
/>
</div>
)
})}
{hasMoreLater && (
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
<Virtuoso
ref={messageVirtuosoRef}
className="message-virtuoso"
customScrollParent={messageListScrollParent ?? undefined}
data={messages}
overscan={360}
atBottomStateChange={handleMessageAtBottomStateChange}
atTopStateChange={handleMessageAtTopStateChange}
rangeChanged={handleMessageRangeChanged}
computeItemKey={(_, msg) => getMessageKey(msg)}
components={messageVirtuosoComponents}
itemContent={renderMessageListItem}
/>
)}
{/* 回到底部按钮 */}
@@ -6665,6 +6856,7 @@ function MessageBubble({
showTime,
myAvatarUrl,
isGroupChat,
autoTranscribeVoiceEnabled,
onRequireModelDownload,
onContextMenu,
isSelectionMode,
@@ -6677,6 +6869,7 @@ function MessageBubble({
showTime?: boolean;
myAvatarUrl?: string;
isGroupChat?: boolean;
autoTranscribeVoiceEnabled?: boolean;
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
isSelectionMode?: boolean;
@@ -6737,7 +6930,6 @@ function MessageBubble({
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
const voiceTranscriptRequestedRef = useRef(false)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
const [voiceDuration, setVoiceDuration] = useState(0)
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
@@ -6787,15 +6979,6 @@ function MessageBubble({
}
}, [isVideo, message.videoMd5, message.content, message.parsedContent])
// 加载自动转文字配置
useEffect(() => {
const loadConfig = async () => {
const enabled = await configService.getAutoTranscribeVoice()
setAutoTranscribeVoice(enabled)
}
loadConfig()
}, [])
const formatTime = (timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
const date = new Date(timestamp * 1000)
@@ -7427,25 +7610,14 @@ function MessageBubble({
void requestVideoInfo()
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
// 根据设置决定是否自动转写
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
setAutoTranscribeEnabled(value === true)
})
}, [])
useEffect(() => {
if (!autoTranscribeEnabled) return
if (!autoTranscribeVoiceEnabled) 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])
}, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript])
// Selection mode handling removed from here to allow normal rendering
// We will wrap the output instead
@@ -8689,4 +8861,24 @@ function MessageBubble({
)
}
const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
if (prevProps.message !== nextProps.message) return false
if (prevProps.messageKey !== nextProps.messageKey) return false
if (prevProps.showTime !== nextProps.showTime) return false
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
if (prevProps.isSelected !== nextProps.isSelected) return false
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
return (
prevProps.session.username === nextProps.session.username &&
prevProps.session.displayName === nextProps.session.displayName &&
prevProps.session.avatarUrl === nextProps.session.avatarUrl
)
})
export default ChatPage