mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
计划优化 P5/5
This commit is contained in:
@@ -14,6 +14,7 @@ class CloudControlService {
|
|||||||
private deviceId: string = ''
|
private deviceId: string = ''
|
||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
private pages: Set<string> = new Set()
|
private pages: Set<string> = new Set()
|
||||||
|
private platformVersionCache: string | null = null
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.deviceId = this.getDeviceId()
|
this.deviceId = this.getDeviceId()
|
||||||
@@ -47,7 +48,12 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getPlatformVersion(): string {
|
private getPlatformVersion(): string {
|
||||||
|
if (this.platformVersionCache) {
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
|
const fs = require('fs')
|
||||||
const platform = process.platform
|
const platform = process.platform
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
@@ -59,21 +65,79 @@ class CloudControlService {
|
|||||||
|
|
||||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||||
if (major === 10 && minor === 0 && build >= 22000) {
|
if (major === 10 && minor === 0 && build >= 22000) {
|
||||||
return 'Windows 11'
|
this.platformVersionCache = 'Windows 11'
|
||||||
|
return this.platformVersionCache
|
||||||
} else if (major === 10) {
|
} else if (major === 10) {
|
||||||
return 'Windows 10'
|
this.platformVersionCache = 'Windows 10'
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
return `Windows ${release}`
|
this.platformVersionCache = `Windows ${release}`
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
if (platform === 'darwin') {
|
||||||
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
return `macOS ${macVersion}`
|
this.platformVersionCache = `macOS ${macVersion}`
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
return platform
|
if (platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
|
||||||
|
for (const filePath of osReleasePaths) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const values: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmed.indexOf('=')
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex)
|
||||||
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.PRETTY_NAME) {
|
||||||
|
this.platformVersionCache = values.PRETTY_NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME && values.VERSION_ID) {
|
||||||
|
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME) {
|
||||||
|
this.platformVersionCache = values.NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = `Linux ${os.release()}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = platform
|
||||||
|
return this.platformVersionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
recordPage(pageName: string) {
|
recordPage(pageName: string) {
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ export class ConfigService {
|
|||||||
|
|
||||||
const storeOptions: any = {
|
const storeOptions: any = {
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults
|
defaults,
|
||||||
|
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
}
|
}
|
||||||
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
if (runningInWorker) {
|
if (runningInWorker) {
|
||||||
@@ -131,7 +132,6 @@ export class ConfigService {
|
|||||||
if (cwd) {
|
if (cwd) {
|
||||||
storeOptions.cwd = cwd
|
storeOptions.cwd = cwd
|
||||||
}
|
}
|
||||||
storeOptions.projectName = String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -133,6 +133,29 @@ export interface ExportProgress {
|
|||||||
exportedMessages?: number
|
exportedMessages?: number
|
||||||
estimatedTotalMessages?: number
|
estimatedTotalMessages?: number
|
||||||
writtenFiles?: number
|
writtenFiles?: number
|
||||||
|
mediaDoneFiles?: number
|
||||||
|
mediaCacheHitFiles?: number
|
||||||
|
mediaCacheMissFiles?: number
|
||||||
|
mediaCacheFillFiles?: number
|
||||||
|
mediaDedupReuseFiles?: number
|
||||||
|
mediaBytesWritten?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaExportTelemetry {
|
||||||
|
doneFiles: number
|
||||||
|
cacheHitFiles: number
|
||||||
|
cacheMissFiles: number
|
||||||
|
cacheFillFiles: number
|
||||||
|
dedupReuseFiles: number
|
||||||
|
bytesWritten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaSourceResolution {
|
||||||
|
sourcePath: string
|
||||||
|
cacheHit: boolean
|
||||||
|
cachePath?: string
|
||||||
|
fileStat?: { size: number; mtimeMs: number }
|
||||||
|
dedupeKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportTaskControl {
|
interface ExportTaskControl {
|
||||||
@@ -218,6 +241,14 @@ class ExportService {
|
|||||||
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
||||||
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
||||||
private mediaFileCacheReadyDirs = new Set<string>()
|
private mediaFileCacheReadyDirs = new Set<string>()
|
||||||
|
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||||
|
private mediaRunSourceDedupMap = new Map<string, string>()
|
||||||
|
private mediaFileCacheCleanupPending: Promise<void> | null = null
|
||||||
|
private mediaFileCacheLastCleanupAt = 0
|
||||||
|
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
|
||||||
|
private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024
|
||||||
|
private readonly mediaFileCacheMaxFiles = 120000
|
||||||
|
private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -456,6 +487,62 @@ class ExportService {
|
|||||||
return path.join(this.configService.getCacheBasePath(), 'export-media-files')
|
return path.join(this.configService.getCacheBasePath(), 'export-media-files')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createEmptyMediaTelemetry(): MediaExportTelemetry {
|
||||||
|
return {
|
||||||
|
doneFiles: 0,
|
||||||
|
cacheHitFiles: 0,
|
||||||
|
cacheMissFiles: 0,
|
||||||
|
cacheFillFiles: 0,
|
||||||
|
dedupReuseFiles: 0,
|
||||||
|
bytesWritten: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetMediaRuntimeState(): void {
|
||||||
|
this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
|
||||||
|
this.mediaRunSourceDedupMap.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearMediaRuntimeState(): void {
|
||||||
|
this.mediaExportTelemetry = null
|
||||||
|
this.mediaRunSourceDedupMap.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
|
||||||
|
const stats = this.mediaExportTelemetry
|
||||||
|
if (!stats) return {}
|
||||||
|
return {
|
||||||
|
mediaDoneFiles: stats.doneFiles,
|
||||||
|
mediaCacheHitFiles: stats.cacheHitFiles,
|
||||||
|
mediaCacheMissFiles: stats.cacheMissFiles,
|
||||||
|
mediaCacheFillFiles: stats.cacheFillFiles,
|
||||||
|
mediaDedupReuseFiles: stats.dedupReuseFiles,
|
||||||
|
mediaBytesWritten: stats.bytesWritten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private noteMediaTelemetry(delta: Partial<MediaExportTelemetry>): void {
|
||||||
|
if (!this.mediaExportTelemetry) return
|
||||||
|
if (Number.isFinite(delta.doneFiles)) {
|
||||||
|
this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0)))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delta.cacheHitFiles)) {
|
||||||
|
this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0)))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delta.cacheMissFiles)) {
|
||||||
|
this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0)))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delta.cacheFillFiles)) {
|
||||||
|
this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0)))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delta.dedupReuseFiles)) {
|
||||||
|
this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0)))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delta.bytesWritten)) {
|
||||||
|
this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureMediaFileCacheDir(dirPath: string): Promise<void> {
|
private async ensureMediaFileCacheDir(dirPath: string): Promise<void> {
|
||||||
if (this.mediaFileCacheReadyDirs.has(dirPath)) return
|
if (this.mediaFileCacheReadyDirs.has(dirPath)) return
|
||||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
@@ -529,6 +616,7 @@ class ExportService {
|
|||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => { })
|
await fs.promises.rm(tempPath, { force: true }).catch(() => { })
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
this.noteMediaTelemetry({ cacheFillFiles: 1 })
|
||||||
return cachePath
|
return cachePath
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -544,15 +632,185 @@ class ExportService {
|
|||||||
private async resolvePreferredMediaSource(
|
private async resolvePreferredMediaSource(
|
||||||
kind: 'image' | 'video' | 'emoji',
|
kind: 'image' | 'video' | 'emoji',
|
||||||
sourcePath: string
|
sourcePath: string
|
||||||
): Promise<string> {
|
): Promise<MediaSourceResolution> {
|
||||||
const resolved = await this.resolveMediaFileCachePath(kind, sourcePath)
|
const resolved = await this.resolveMediaFileCachePath(kind, sourcePath)
|
||||||
if (!resolved) return sourcePath
|
if (!resolved) {
|
||||||
|
return {
|
||||||
|
sourcePath,
|
||||||
|
cacheHit: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dedupeKey = `${kind}\u001f${resolved.cachePath}`
|
||||||
if (await this.pathExists(resolved.cachePath)) {
|
if (await this.pathExists(resolved.cachePath)) {
|
||||||
return resolved.cachePath
|
return {
|
||||||
|
sourcePath: resolved.cachePath,
|
||||||
|
cacheHit: true,
|
||||||
|
cachePath: resolved.cachePath,
|
||||||
|
fileStat: resolved.fileStat,
|
||||||
|
dedupeKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 未命中缓存时异步回填,不阻塞当前导出路径
|
// 未命中缓存时异步回填,不阻塞当前导出路径
|
||||||
void this.populateMediaFileCache(kind, sourcePath)
|
void this.populateMediaFileCache(kind, sourcePath)
|
||||||
return sourcePath
|
return {
|
||||||
|
sourcePath,
|
||||||
|
cacheHit: false,
|
||||||
|
cachePath: resolved.cachePath,
|
||||||
|
fileStat: resolved.fileStat,
|
||||||
|
dedupeKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHardlinkFallbackError(code: string | undefined): boolean {
|
||||||
|
return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> {
|
||||||
|
try {
|
||||||
|
await fs.promises.link(sourcePath, destPath)
|
||||||
|
return { success: true, linked: true }
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||||
|
if (code === 'EEXIST') {
|
||||||
|
return { success: true, linked: true }
|
||||||
|
}
|
||||||
|
if (!this.isHardlinkFallbackError(code)) {
|
||||||
|
return { success: false, code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = await this.copyFileOptimized(sourcePath, destPath)
|
||||||
|
if (!copied.success) return copied
|
||||||
|
return { success: true, linked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyMediaWithCacheAndDedup(
|
||||||
|
kind: 'image' | 'video' | 'emoji',
|
||||||
|
sourcePath: string,
|
||||||
|
destPath: string
|
||||||
|
): Promise<{ success: boolean; code?: string }> {
|
||||||
|
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
||||||
|
if (resolved.cacheHit) {
|
||||||
|
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
||||||
|
} else {
|
||||||
|
this.noteMediaTelemetry({ cacheMissFiles: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeKey = resolved.dedupeKey
|
||||||
|
if (dedupeKey) {
|
||||||
|
const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey)
|
||||||
|
if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) {
|
||||||
|
const reused = await this.hardlinkOrCopyFile(reusedPath, destPath)
|
||||||
|
if (!reused.success) return reused
|
||||||
|
this.noteMediaTelemetry({
|
||||||
|
doneFiles: 1,
|
||||||
|
dedupReuseFiles: 1,
|
||||||
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = resolved.cacheHit
|
||||||
|
? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath)
|
||||||
|
: await this.copyFileOptimized(resolved.sourcePath, destPath)
|
||||||
|
if (!copied.success) return copied
|
||||||
|
|
||||||
|
if (dedupeKey) {
|
||||||
|
this.mediaRunSourceDedupMap.set(dedupeKey, destPath)
|
||||||
|
}
|
||||||
|
this.noteMediaTelemetry({
|
||||||
|
doneFiles: 1,
|
||||||
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private triggerMediaFileCacheCleanup(force = false): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return
|
||||||
|
if (this.mediaFileCacheCleanupPending) return
|
||||||
|
this.mediaFileCacheLastCleanupAt = now
|
||||||
|
|
||||||
|
this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => {
|
||||||
|
this.mediaFileCacheCleanupPending = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupMediaFileCache(): Promise<void> {
|
||||||
|
const root = this.getMediaFileCacheRoot()
|
||||||
|
if (!await this.pathExists(root)) return
|
||||||
|
const now = Date.now()
|
||||||
|
const files: Array<{ filePath: string; size: number; mtimeMs: number }> = []
|
||||||
|
const dirs: string[] = []
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string
|
||||||
|
dirs.push(current)
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(current, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(entryPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(entryPath)
|
||||||
|
if (!stat.isFile()) continue
|
||||||
|
files.push({
|
||||||
|
filePath: entryPath,
|
||||||
|
size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0,
|
||||||
|
mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0
|
||||||
|
})
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
let totalBytes = files.reduce((sum, item) => sum + item.size, 0)
|
||||||
|
let totalFiles = files.length
|
||||||
|
const ttlThreshold = now - this.mediaFileCacheTtlMs
|
||||||
|
const removalSet = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of files) {
|
||||||
|
if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) {
|
||||||
|
removalSet.add(item.filePath)
|
||||||
|
totalBytes -= item.size
|
||||||
|
totalFiles -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) {
|
||||||
|
const ordered = files
|
||||||
|
.filter((item) => !removalSet.has(item.filePath))
|
||||||
|
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
||||||
|
for (const item of ordered) {
|
||||||
|
if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break
|
||||||
|
removalSet.add(item.filePath)
|
||||||
|
totalBytes -= item.size
|
||||||
|
totalFiles -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removalSet.size === 0) return
|
||||||
|
|
||||||
|
for (const filePath of removalSet) {
|
||||||
|
await fs.promises.rm(filePath, { force: true }).catch(() => { })
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs.sort((a, b) => b.length - a.length)
|
||||||
|
for (const dirPath of dirs) {
|
||||||
|
if (dirPath === root) continue
|
||||||
|
await fs.promises.rmdir(dirPath).catch(() => { })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isMediaExportEnabled(options: ExportOptions): boolean {
|
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||||
@@ -2398,6 +2656,7 @@ class ExportService {
|
|||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>
|
||||||
}
|
}
|
||||||
@@ -2431,7 +2690,14 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 43 && options.exportVideos) {
|
if (localType === 43 && options.exportVideos) {
|
||||||
return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
return this.exportVideo(
|
||||||
|
msg,
|
||||||
|
sessionId,
|
||||||
|
mediaRootDir,
|
||||||
|
mediaRelativePrefix,
|
||||||
|
options.dirCache,
|
||||||
|
options.includeVideoPoster === true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -2510,7 +2776,13 @@ class ExportService {
|
|||||||
const fileName = `${messageId}_${imageKey}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
await fs.promises.writeFile(destPath, Buffer.from(base64Data, 'base64'))
|
const buffer = Buffer.from(base64Data, 'base64')
|
||||||
|
await fs.promises.writeFile(destPath, buffer)
|
||||||
|
this.noteMediaTelemetry({
|
||||||
|
doneFiles: 1,
|
||||||
|
cacheMissFiles: 1,
|
||||||
|
bytesWritten: buffer.length
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||||
@@ -2524,8 +2796,7 @@ class ExportService {
|
|||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${messageId}_${imageKey}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
const preferredSource = await this.resolvePreferredMediaSource('image', sourcePath)
|
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath)
|
||||||
const copied = await this.copyFileOptimized(preferredSource, destPath)
|
|
||||||
if (!copied.success) {
|
if (!copied.success) {
|
||||||
if (copied.code === 'ENOENT') {
|
if (copied.code === 'ENOENT') {
|
||||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||||
@@ -2692,6 +2963,10 @@ class ExportService {
|
|||||||
// voiceResult.data 是 base64 编码的 wav 数据
|
// voiceResult.data 是 base64 编码的 wav 数据
|
||||||
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||||
await fs.promises.writeFile(destPath, wavBuffer)
|
await fs.promises.writeFile(destPath, wavBuffer)
|
||||||
|
this.noteMediaTelemetry({
|
||||||
|
doneFiles: 1,
|
||||||
|
bytesWritten: wavBuffer.length
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||||
@@ -2746,8 +3021,7 @@ class ExportService {
|
|||||||
const key = msg.emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
const preferredSource = await this.resolvePreferredMediaSource('emoji', localPath)
|
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath)
|
||||||
const copied = await this.copyFileOptimized(preferredSource, destPath)
|
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2768,7 +3042,8 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
includePoster = false
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const videoMd5 = msg.videoMd5
|
const videoMd5 = msg.videoMd5
|
||||||
@@ -2780,7 +3055,7 @@ class ExportService {
|
|||||||
dirCache?.add(videosDir)
|
dirCache?.add(videosDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoInfo = await videoService.getVideoInfo(videoMd5)
|
const videoInfo = await videoService.getVideoInfo(videoMd5, { includePoster })
|
||||||
if (!videoInfo.exists || !videoInfo.videoUrl) {
|
if (!videoInfo.exists || !videoInfo.videoUrl) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -2789,14 +3064,13 @@ class ExportService {
|
|||||||
const fileName = path.basename(sourcePath)
|
const fileName = path.basename(sourcePath)
|
||||||
const destPath = path.join(videosDir, fileName)
|
const destPath = path.join(videosDir, fileName)
|
||||||
|
|
||||||
const preferredSource = await this.resolvePreferredMediaSource('video', sourcePath)
|
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath)
|
||||||
const copied = await this.copyFileOptimized(preferredSource, destPath)
|
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName),
|
||||||
kind: 'video',
|
kind: 'video',
|
||||||
posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl
|
posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null
|
return null
|
||||||
@@ -3854,6 +4128,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3870,6 +4145,7 @@ class ExportService {
|
|||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -3883,7 +4159,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -4341,6 +4618,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4356,6 +4634,7 @@ class ExportService {
|
|||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -4369,7 +4648,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -5152,6 +5432,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -5167,6 +5448,7 @@ class ExportService {
|
|||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -5180,7 +5462,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -5822,6 +6105,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -5837,6 +6121,7 @@ class ExportService {
|
|||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -5850,7 +6135,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -6164,6 +6450,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6178,7 +6465,9 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -6191,7 +6480,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -6574,6 +6864,7 @@ class ExportService {
|
|||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
phaseLabel: `导出媒体 0/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot(),
|
||||||
estimatedTotalMessages: totalMessages
|
estimatedTotalMessages: totalMessages
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6588,6 +6879,7 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
@@ -6603,7 +6895,8 @@ class ExportService {
|
|||||||
phase: 'exporting-media',
|
phase: 'exporting-media',
|
||||||
phaseProgress: mediaExported,
|
phaseProgress: mediaExported,
|
||||||
phaseTotal: mediaMessages.length,
|
phaseTotal: mediaMessages.length,
|
||||||
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`
|
phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`,
|
||||||
|
...this.getMediaTelemetrySnapshot()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -7276,8 +7569,12 @@ class ExportService {
|
|||||||
const successSessionIds: string[] = []
|
const successSessionIds: string[] = []
|
||||||
const failedSessionIds: string[] = []
|
const failedSessionIds: string[] = []
|
||||||
const progressEmitter = this.createProgressEmitter(onProgress)
|
const progressEmitter = this.createProgressEmitter(onProgress)
|
||||||
|
let attachMediaTelemetry = false
|
||||||
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => {
|
||||||
progressEmitter.emit(progress, options)
|
const payload = attachMediaTelemetry
|
||||||
|
? { ...progress, ...this.getMediaTelemetrySnapshot() }
|
||||||
|
: progress
|
||||||
|
progressEmitter.emit(payload, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -7286,12 +7583,17 @@ class ExportService {
|
|||||||
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
|
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.resetMediaRuntimeState()
|
||||||
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
|
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
|
||||||
? { ...options, exportVoiceAsText: false }
|
? { ...options, exportVoiceAsText: false }
|
||||||
: options
|
: options
|
||||||
|
|
||||||
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||||
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
|
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
|
||||||
|
attachMediaTelemetry = exportMediaEnabled
|
||||||
|
if (exportMediaEnabled) {
|
||||||
|
this.triggerMediaFileCacheCleanup()
|
||||||
|
}
|
||||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||||
? rawWriteLayout
|
? rawWriteLayout
|
||||||
@@ -7745,6 +8047,8 @@ class ExportService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
progressEmitter.flush()
|
progressEmitter.flush()
|
||||||
return { success: false, successCount, failCount, error: String(e) }
|
return { success: false, successCount, failCount, error: String(e) }
|
||||||
|
} finally {
|
||||||
|
this.clearMediaRuntimeState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ class VideoService {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string): VideoInfo | null {
|
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
if (!normalizedMd5) return null
|
if (!normalizedMd5) return null
|
||||||
|
|
||||||
@@ -371,6 +371,12 @@ class VideoService {
|
|||||||
const entry = index.get(key)
|
const entry = index.get(key)
|
||||||
if (!entry?.videoPath) continue
|
if (!entry?.videoPath) continue
|
||||||
if (!existsSync(entry.videoPath)) continue
|
if (!existsSync(entry.videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
videoUrl: entry.videoPath,
|
videoUrl: entry.videoPath,
|
||||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||||
@@ -382,7 +388,7 @@ class VideoService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null {
|
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||||
try {
|
try {
|
||||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
.filter((dir) => {
|
.filter((dir) => {
|
||||||
@@ -399,6 +405,12 @@ class VideoService {
|
|||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
if (!existsSync(videoPath)) continue
|
if (!existsSync(videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||||
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
@@ -420,8 +432,9 @@ class VideoService {
|
|||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
*/
|
*/
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||||
|
const includePoster = options?.includePoster !== false
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
@@ -433,7 +446,7 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
const cacheKey = `${scopeKey}|${normalizedMd5}`
|
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||||
|
|
||||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
if (cachedInfo) return cachedInfo
|
if (cachedInfo) return cachedInfo
|
||||||
@@ -452,13 +465,13 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5)
|
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||||
if (indexed) {
|
if (indexed) {
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
return indexed
|
return indexed
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5)
|
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -1249,25 +1249,18 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private preserveInt64FieldsInJson(jsonStr: string, fieldNames: string[]): string {
|
|
||||||
let normalized = String(jsonStr || '')
|
|
||||||
for (const fieldName of fieldNames) {
|
|
||||||
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
const pattern = new RegExp(`("${escaped}"\\s*:\\s*)(-?\\d{16,})`, 'g')
|
|
||||||
normalized = normalized.replace(pattern, '$1"$2"')
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseMessageJson(jsonStr: string): any {
|
private parseMessageJson(jsonStr: string): any {
|
||||||
const normalized = this.preserveInt64FieldsInJson(jsonStr, [
|
const raw = String(jsonStr || '')
|
||||||
'server_id',
|
if (!raw) return []
|
||||||
'serverId',
|
// 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。
|
||||||
'ServerId',
|
const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw)
|
||||||
'msg_server_id',
|
if (!needsInt64Normalize) {
|
||||||
'msgServerId',
|
return JSON.parse(raw)
|
||||||
'MsgServerId'
|
}
|
||||||
])
|
const normalized = raw.replace(
|
||||||
|
/("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g,
|
||||||
|
'$1"$2"'
|
||||||
|
)
|
||||||
return JSON.parse(normalized)
|
return JSON.parse(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
|
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react'
|
||||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
import '../styles/batchTranscribe.scss'
|
import '../styles/batchTranscribe.scss'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
result,
|
result,
|
||||||
sessionName,
|
sessionName,
|
||||||
startTime,
|
startTime,
|
||||||
|
taskType,
|
||||||
setShowToast,
|
setShowToast,
|
||||||
setShowResult
|
setShowResult
|
||||||
} = useBatchTranscribeStore()
|
} = useBatchTranscribeStore()
|
||||||
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-progress-toast-header">
|
<div className="batch-progress-toast-header">
|
||||||
<div className="batch-progress-toast-title">
|
<div className="batch-progress-toast-title">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="batch-modal-header">
|
<div className="batch-modal-header">
|
||||||
<CheckCircle size={20} />
|
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||||
<h3>转写完成</h3>
|
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-body">
|
<div className="batch-modal-body">
|
||||||
<div className="result-summary">
|
<div className="result-summary">
|
||||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
{result.fail > 0 && (
|
{result.fail > 0 && (
|
||||||
<div className="result-tip">
|
<div className="result-tip">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3713,6 +3713,36 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-task-switch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.batch-task-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 50%, var(--border-color));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.batch-dates-list-wrap {
|
.batch-dates-list-wrap {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
|
||||||
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { getEmojiPath } from 'wechat-emojis'
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
@@ -855,6 +855,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
|
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
|
||||||
const topRangeLoadLockRef = useRef(false)
|
const topRangeLoadLockRef = useRef(false)
|
||||||
const bottomRangeLoadLockRef = useRef(false)
|
const bottomRangeLoadLockRef = useRef(false)
|
||||||
|
const suppressAutoLoadLaterRef = useRef(false)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
@@ -939,6 +940,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||||
const {
|
const {
|
||||||
isBatchTranscribing,
|
isBatchTranscribing,
|
||||||
|
runningBatchVoiceTaskType,
|
||||||
batchTranscribeProgress,
|
batchTranscribeProgress,
|
||||||
startTranscribe,
|
startTranscribe,
|
||||||
updateProgress,
|
updateProgress,
|
||||||
@@ -946,6 +948,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setShowBatchProgress
|
setShowBatchProgress
|
||||||
} = useBatchTranscribeStore(useShallow((state) => ({
|
} = useBatchTranscribeStore(useShallow((state) => ({
|
||||||
isBatchTranscribing: state.isBatchTranscribing,
|
isBatchTranscribing: state.isBatchTranscribing,
|
||||||
|
runningBatchVoiceTaskType: state.taskType,
|
||||||
batchTranscribeProgress: state.progress,
|
batchTranscribeProgress: state.progress,
|
||||||
startTranscribe: state.startTranscribe,
|
startTranscribe: state.startTranscribe,
|
||||||
updateProgress: state.updateProgress,
|
updateProgress: state.updateProgress,
|
||||||
@@ -972,6 +975,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||||
|
const [batchVoiceTaskType, setBatchVoiceTaskType] = useState<BatchVoiceTaskType>('transcribe')
|
||||||
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
|
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
|
||||||
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
|
||||||
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
|
||||||
@@ -4054,6 +4058,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (
|
if (
|
||||||
range.endIndex >= total - 3 &&
|
range.endIndex >= total - 3 &&
|
||||||
!bottomRangeLoadLockRef.current &&
|
!bottomRangeLoadLockRef.current &&
|
||||||
|
!suppressAutoLoadLaterRef.current &&
|
||||||
!isLoadingMore &&
|
!isLoadingMore &&
|
||||||
!isLoadingMessages &&
|
!isLoadingMessages &&
|
||||||
hasMoreLater &&
|
hasMoreLater &&
|
||||||
@@ -4122,6 +4127,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
if (!effectiveAtBottom) {
|
if (!effectiveAtBottom) {
|
||||||
bottomRangeLoadLockRef.current = false
|
bottomRangeLoadLockRef.current = false
|
||||||
|
// 用户主动离开底部后,解除“搜索跳转后的自动向后加载抑制”
|
||||||
|
suppressAutoLoadLaterRef.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -4142,6 +4149,21 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow))
|
||||||
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
||||||
|
|
||||||
|
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (event.deltaY <= 18) return
|
||||||
|
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return
|
||||||
|
const listEl = messageListRef.current
|
||||||
|
if (!listEl) return
|
||||||
|
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
||||||
|
if (distanceFromBottom > 96) return
|
||||||
|
if (bottomRangeLoadLockRef.current) return
|
||||||
|
|
||||||
|
// 用户明确向下滚动时允许加载后续消息
|
||||||
|
suppressAutoLoadLaterRef.current = false
|
||||||
|
bottomRangeLoadLockRef.current = true
|
||||||
|
void loadLaterMessages()
|
||||||
|
}, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages])
|
||||||
|
|
||||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||||
if (!atTop) {
|
if (!atTop) {
|
||||||
topRangeLoadLockRef.current = false
|
topRangeLoadLockRef.current = false
|
||||||
@@ -4213,6 +4235,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setCurrentOffset(0)
|
setCurrentOffset(0)
|
||||||
setJumpStartTime(0)
|
setJumpStartTime(0)
|
||||||
setJumpEndTime(anchorEndTime)
|
setJumpEndTime(anchorEndTime)
|
||||||
|
// 搜索跳转后默认不自动回流到最新消息,仅在用户主动向下滚动时加载后续
|
||||||
|
suppressAutoLoadLaterRef.current = true
|
||||||
flashNewMessages([targetMessageKey])
|
flashNewMessages([targetMessageKey])
|
||||||
void loadMessages(targetSessionId, 0, 0, anchorEndTime, false, {
|
void loadMessages(targetSessionId, 0, 0, anchorEndTime, false, {
|
||||||
inSessionJumpRequestSeq: requestSeq
|
inSessionJumpRequestSeq: requestSeq
|
||||||
@@ -5015,6 +5039,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setBatchVoiceCount(voiceMessages.length)
|
setBatchVoiceCount(voiceMessages.length)
|
||||||
setBatchVoiceDates(sortedDates)
|
setBatchVoiceDates(sortedDates)
|
||||||
setBatchSelectedDates(new Set(sortedDates))
|
setBatchSelectedDates(new Set(sortedDates))
|
||||||
|
setBatchVoiceTaskType('transcribe')
|
||||||
setShowBatchConfirm(true)
|
setShowBatchConfirm(true)
|
||||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||||
|
|
||||||
@@ -5078,7 +5103,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}, [currentSessionId, navigate, isGroupChatSession])
|
}, [currentSessionId, navigate, isGroupChatSession])
|
||||||
|
|
||||||
// 确认批量转写
|
// 确认批量语音任务(解密/转写)
|
||||||
const confirmBatchTranscribe = useCallback(async () => {
|
const confirmBatchTranscribe = useCallback(async () => {
|
||||||
if (!currentSessionId) return
|
if (!currentSessionId) return
|
||||||
|
|
||||||
@@ -5110,8 +5135,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const session = sessions.find(s => s.username === currentSessionId)
|
const session = sessions.find(s => s.username === currentSessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
startTranscribe(voiceMessages.length, session.displayName || session.username)
|
const taskType = batchVoiceTaskType
|
||||||
|
startTranscribe(voiceMessages.length, session.displayName || session.username, taskType)
|
||||||
|
|
||||||
|
if (taskType === 'transcribe') {
|
||||||
// 检查模型状态
|
// 检查模型状态
|
||||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||||
if (!modelStatus?.exists) {
|
if (!modelStatus?.exists) {
|
||||||
@@ -5119,14 +5146,24 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
finishTranscribe(0, 0)
|
finishTranscribe(0, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
let completedCount = 0
|
let completedCount = 0
|
||||||
const concurrency = 10
|
const concurrency = taskType === 'decrypt' ? 12 : 10
|
||||||
|
|
||||||
const transcribeOne = async (msg: Message) => {
|
const runOne = async (msg: Message) => {
|
||||||
try {
|
try {
|
||||||
|
if (taskType === 'decrypt') {
|
||||||
|
const result = await window.electronAPI.chat.getVoiceData(
|
||||||
|
session.username,
|
||||||
|
String(msg.localId),
|
||||||
|
msg.createTime,
|
||||||
|
msg.serverIdRaw || msg.serverId
|
||||||
|
)
|
||||||
|
return { success: Boolean(result.success && result.data) }
|
||||||
|
}
|
||||||
const result = await window.electronAPI.chat.getVoiceTranscript(
|
const result = await window.electronAPI.chat.getVoiceTranscript(
|
||||||
session.username,
|
session.username,
|
||||||
String(msg.localId),
|
String(msg.localId),
|
||||||
@@ -5140,7 +5177,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
||||||
const batch = voiceMessages.slice(i, i + concurrency)
|
const batch = voiceMessages.slice(i, i + concurrency)
|
||||||
const results = await Promise.all(batch.map(msg => transcribeOne(msg)))
|
const results = await Promise.all(batch.map(msg => runOne(msg)))
|
||||||
|
|
||||||
results.forEach(result => {
|
results.forEach(result => {
|
||||||
if (result.success) successCount++
|
if (result.success) successCount++
|
||||||
@@ -5151,7 +5188,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finishTranscribe(successCount, failCount)
|
finishTranscribe(successCount, failCount)
|
||||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe])
|
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe])
|
||||||
|
|
||||||
// 批量转写:按日期的消息数量
|
// 批量转写:按日期的消息数量
|
||||||
const batchCountByDate = useMemo(() => {
|
const batchCountByDate = useMemo(() => {
|
||||||
@@ -5172,6 +5209,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
).length
|
).length
|
||||||
}, [batchVoiceMessages, batchSelectedDates])
|
}, [batchVoiceMessages, batchSelectedDates])
|
||||||
|
|
||||||
|
const batchVoiceTaskTitle = batchVoiceTaskType === 'decrypt' ? '批量解密语音' : '批量语音转文字'
|
||||||
|
const batchVoiceTaskVerb = batchVoiceTaskType === 'decrypt' ? '解密' : '转写'
|
||||||
|
const batchVoiceTaskMinutes = Math.ceil(
|
||||||
|
batchSelectedMessageCount * (batchVoiceTaskType === 'decrypt' ? 0.6 : 2) / 60
|
||||||
|
)
|
||||||
|
|
||||||
const toggleBatchDate = useCallback((date: string) => {
|
const toggleBatchDate = useCallback((date: string) => {
|
||||||
setBatchSelectedDates(prev => {
|
setBatchSelectedDates(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -5965,7 +6008,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!currentSessionId}
|
disabled={!currentSessionId}
|
||||||
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
|
title={isBatchTranscribing
|
||||||
|
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度`
|
||||||
|
: '批量语音处理(解密/转文字)'}
|
||||||
>
|
>
|
||||||
{isBatchTranscribing ? (
|
{isBatchTranscribing ? (
|
||||||
<Loader2 size={18} className="spin" />
|
<Loader2 size={18} className="spin" />
|
||||||
@@ -6176,6 +6221,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<div
|
<div
|
||||||
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
||||||
ref={handleMessageListScrollParentRef}
|
ref={handleMessageListScrollParentRef}
|
||||||
|
onWheel={handleMessageListWheel}
|
||||||
>
|
>
|
||||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
|
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
|
||||||
<div className="empty-chat-inline">
|
<div className="empty-chat-inline">
|
||||||
@@ -6583,10 +6629,26 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="batch-modal-header">
|
<div className="batch-modal-header">
|
||||||
<Mic size={20} />
|
<Mic size={20} />
|
||||||
<h3>批量语音转文字</h3>
|
<h3>{batchVoiceTaskTitle}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-body">
|
<div className="batch-modal-body">
|
||||||
<p>选择要转写的日期(仅显示有语音的日期),然后开始转写。</p>
|
<p>先选择任务类型,再选择日期(仅显示有语音的日期),然后开始处理。</p>
|
||||||
|
<div className="batch-task-switch" role="tablist" aria-label="语音批量任务类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`batch-task-btn${batchVoiceTaskType === 'decrypt' ? ' active' : ''}`}
|
||||||
|
onClick={() => setBatchVoiceTaskType('decrypt')}
|
||||||
|
>
|
||||||
|
批量解密语音
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`batch-task-btn${batchVoiceTaskType === 'transcribe' ? ' active' : ''}`}
|
||||||
|
onClick={() => setBatchVoiceTaskType('transcribe')}
|
||||||
|
>
|
||||||
|
批量转文字
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{batchVoiceDates.length > 0 && (
|
{batchVoiceDates.length > 0 && (
|
||||||
<div className="batch-dates-list-wrap">
|
<div className="batch-dates-list-wrap">
|
||||||
<div className="batch-dates-actions">
|
<div className="batch-dates-actions">
|
||||||
@@ -6621,12 +6683,16 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<span className="label">预计耗时:</span>
|
<span className="label">预计耗时:</span>
|
||||||
<span className="value">约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟</span>
|
<span className="value">约 {batchVoiceTaskMinutes} 分钟</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-warning">
|
<div className="batch-warning">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。</span>
|
<span>
|
||||||
|
{batchVoiceTaskType === 'decrypt'
|
||||||
|
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。'
|
||||||
|
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-footer">
|
<div className="batch-modal-footer">
|
||||||
@@ -6635,7 +6701,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}>
|
<button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}>
|
||||||
<Mic size={16} />
|
<Mic size={16} />
|
||||||
开始转写
|
开始{batchVoiceTaskVerb}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ interface TaskProgress {
|
|||||||
estimatedTotalMessages: number
|
estimatedTotalMessages: number
|
||||||
collectedMessages: number
|
collectedMessages: number
|
||||||
writtenFiles: number
|
writtenFiles: number
|
||||||
|
mediaDoneFiles: number
|
||||||
|
mediaCacheHitFiles: number
|
||||||
|
mediaCacheMissFiles: number
|
||||||
|
mediaCacheFillFiles: number
|
||||||
|
mediaDedupReuseFiles: number
|
||||||
|
mediaBytesWritten: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
|
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
|
||||||
@@ -263,7 +269,13 @@ const createEmptyProgress = (): TaskProgress => ({
|
|||||||
exportedMessages: 0,
|
exportedMessages: 0,
|
||||||
estimatedTotalMessages: 0,
|
estimatedTotalMessages: 0,
|
||||||
collectedMessages: 0,
|
collectedMessages: 0,
|
||||||
writtenFiles: 0
|
writtenFiles: 0,
|
||||||
|
mediaDoneFiles: 0,
|
||||||
|
mediaCacheHitFiles: 0,
|
||||||
|
mediaCacheMissFiles: 0,
|
||||||
|
mediaCacheFillFiles: 0,
|
||||||
|
mediaDedupReuseFiles: 0,
|
||||||
|
mediaBytesWritten: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||||
@@ -1302,6 +1314,17 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
: `已收集 ${collectedMessages.toLocaleString()} 条`
|
: `已收集 ${collectedMessages.toLocaleString()} 条`
|
||||||
const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0))
|
const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0))
|
||||||
const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0))
|
const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0))
|
||||||
|
const mediaDoneFiles = Math.max(0, Math.floor(task.progress.mediaDoneFiles || 0))
|
||||||
|
const mediaCacheHitFiles = Math.max(0, Math.floor(task.progress.mediaCacheHitFiles || 0))
|
||||||
|
const mediaCacheMissFiles = Math.max(0, Math.floor(task.progress.mediaCacheMissFiles || 0))
|
||||||
|
const mediaDedupReuseFiles = Math.max(0, Math.floor(task.progress.mediaDedupReuseFiles || 0))
|
||||||
|
const mediaCacheTotal = mediaCacheHitFiles + mediaCacheMissFiles
|
||||||
|
const mediaCacheMetricLabel = mediaCacheTotal > 0
|
||||||
|
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||||
|
: ''
|
||||||
|
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||||
|
? `复用 ${mediaDedupReuseFiles}`
|
||||||
|
: ''
|
||||||
const phaseMetricLabel = phaseTotal > 0
|
const phaseMetricLabel = phaseTotal > 0
|
||||||
? (
|
? (
|
||||||
task.progress.phase === 'exporting-media'
|
task.progress.phase === 'exporting-media'
|
||||||
@@ -1311,6 +1334,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
|
const mediaLiveMetricLabel = task.progress.phase === 'exporting-media'
|
||||||
|
? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '')
|
||||||
|
: ''
|
||||||
const sessionProgressLabel = completedSessionTotal > 0
|
const sessionProgressLabel = completedSessionTotal > 0
|
||||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||||
: '会话处理中'
|
: '会话处理中'
|
||||||
@@ -1336,6 +1362,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
<div className="task-progress-text">
|
<div className="task-progress-text">
|
||||||
{`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`}
|
{`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`}
|
||||||
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||||
|
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
||||||
|
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
||||||
|
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
||||||
{task.status === 'running' && currentSessionRatio !== null
|
{task.status === 'running' && currentSessionRatio !== null
|
||||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -4280,6 +4309,42 @@ function ExportPage() {
|
|||||||
const writtenFiles = Number.isFinite(payload.writtenFiles)
|
const writtenFiles = Number.isFinite(payload.writtenFiles)
|
||||||
? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0))))
|
? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0))))
|
||||||
: task.progress.writtenFiles
|
: task.progress.writtenFiles
|
||||||
|
const prevMediaDoneFiles = Number.isFinite(task.progress.mediaDoneFiles)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaDoneFiles || 0)))
|
||||||
|
: 0
|
||||||
|
const prevMediaCacheHitFiles = Number.isFinite(task.progress.mediaCacheHitFiles)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaCacheHitFiles || 0)))
|
||||||
|
: 0
|
||||||
|
const prevMediaCacheMissFiles = Number.isFinite(task.progress.mediaCacheMissFiles)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaCacheMissFiles || 0)))
|
||||||
|
: 0
|
||||||
|
const prevMediaCacheFillFiles = Number.isFinite(task.progress.mediaCacheFillFiles)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaCacheFillFiles || 0)))
|
||||||
|
: 0
|
||||||
|
const prevMediaDedupReuseFiles = Number.isFinite(task.progress.mediaDedupReuseFiles)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaDedupReuseFiles || 0)))
|
||||||
|
: 0
|
||||||
|
const prevMediaBytesWritten = Number.isFinite(task.progress.mediaBytesWritten)
|
||||||
|
? Math.max(0, Math.floor(Number(task.progress.mediaBytesWritten || 0)))
|
||||||
|
: 0
|
||||||
|
const mediaDoneFiles = Number.isFinite(payload.mediaDoneFiles)
|
||||||
|
? Math.max(prevMediaDoneFiles, Math.max(0, Math.floor(Number(payload.mediaDoneFiles || 0))))
|
||||||
|
: prevMediaDoneFiles
|
||||||
|
const mediaCacheHitFiles = Number.isFinite(payload.mediaCacheHitFiles)
|
||||||
|
? Math.max(prevMediaCacheHitFiles, Math.max(0, Math.floor(Number(payload.mediaCacheHitFiles || 0))))
|
||||||
|
: prevMediaCacheHitFiles
|
||||||
|
const mediaCacheMissFiles = Number.isFinite(payload.mediaCacheMissFiles)
|
||||||
|
? Math.max(prevMediaCacheMissFiles, Math.max(0, Math.floor(Number(payload.mediaCacheMissFiles || 0))))
|
||||||
|
: prevMediaCacheMissFiles
|
||||||
|
const mediaCacheFillFiles = Number.isFinite(payload.mediaCacheFillFiles)
|
||||||
|
? Math.max(prevMediaCacheFillFiles, Math.max(0, Math.floor(Number(payload.mediaCacheFillFiles || 0))))
|
||||||
|
: prevMediaCacheFillFiles
|
||||||
|
const mediaDedupReuseFiles = Number.isFinite(payload.mediaDedupReuseFiles)
|
||||||
|
? Math.max(prevMediaDedupReuseFiles, Math.max(0, Math.floor(Number(payload.mediaDedupReuseFiles || 0))))
|
||||||
|
: prevMediaDedupReuseFiles
|
||||||
|
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
|
||||||
|
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
|
||||||
|
: prevMediaBytesWritten
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
progress: {
|
progress: {
|
||||||
@@ -4295,7 +4360,13 @@ function ExportPage() {
|
|||||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||||
writtenFiles
|
writtenFiles,
|
||||||
|
mediaDoneFiles,
|
||||||
|
mediaCacheHitFiles,
|
||||||
|
mediaCacheMissFiles,
|
||||||
|
mediaCacheFillFiles,
|
||||||
|
mediaDedupReuseFiles,
|
||||||
|
mediaBytesWritten
|
||||||
},
|
},
|
||||||
settledSessionIds: nextSettledSessionIds,
|
settledSessionIds: nextSettledSessionIds,
|
||||||
performance
|
performance
|
||||||
@@ -4336,7 +4407,13 @@ function ExportPage() {
|
|||||||
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
|
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
|
||||||
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
|
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
|
||||||
collectedMessages: task.progress.collectedMessages,
|
collectedMessages: task.progress.collectedMessages,
|
||||||
writtenFiles: task.progress.writtenFiles
|
writtenFiles: task.progress.writtenFiles,
|
||||||
|
mediaDoneFiles: task.progress.mediaDoneFiles,
|
||||||
|
mediaCacheHitFiles: task.progress.mediaCacheHitFiles,
|
||||||
|
mediaCacheMissFiles: task.progress.mediaCacheMissFiles,
|
||||||
|
mediaCacheFillFiles: task.progress.mediaCacheFillFiles,
|
||||||
|
mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles,
|
||||||
|
mediaBytesWritten: task.progress.mediaBytesWritten
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
||||||
|
|
||||||
export interface BatchTranscribeState {
|
export interface BatchTranscribeState {
|
||||||
/** 是否正在批量转写 */
|
/** 是否正在批量转写 */
|
||||||
isBatchTranscribing: boolean
|
isBatchTranscribing: boolean
|
||||||
|
/** 当前批量任务类型 */
|
||||||
|
taskType: BatchVoiceTaskType
|
||||||
/** 转写进度 */
|
/** 转写进度 */
|
||||||
progress: { current: number; total: number }
|
progress: { current: number; total: number }
|
||||||
/** 是否显示进度浮窗 */
|
/** 是否显示进度浮窗 */
|
||||||
@@ -16,7 +20,7 @@ export interface BatchTranscribeState {
|
|||||||
sessionName: string
|
sessionName: string
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
startTranscribe: (total: number, sessionName: string) => void
|
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
|
||||||
updateProgress: (current: number, total: number) => void
|
updateProgress: (current: number, total: number) => void
|
||||||
finishTranscribe: (success: number, fail: number) => void
|
finishTranscribe: (success: number, fail: number) => void
|
||||||
setShowToast: (show: boolean) => void
|
setShowToast: (show: boolean) => void
|
||||||
@@ -26,6 +30,7 @@ export interface BatchTranscribeState {
|
|||||||
|
|
||||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||||
isBatchTranscribing: false,
|
isBatchTranscribing: false,
|
||||||
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
showToast: false,
|
showToast: false,
|
||||||
showResult: false,
|
showResult: false,
|
||||||
@@ -33,8 +38,9 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
sessionName: '',
|
sessionName: '',
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
|
|
||||||
startTranscribe: (total, sessionName) => set({
|
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
||||||
isBatchTranscribing: true,
|
isBatchTranscribing: true,
|
||||||
|
taskType,
|
||||||
showToast: true,
|
showToast: true,
|
||||||
progress: { current: 0, total },
|
progress: { current: 0, total },
|
||||||
showResult: false,
|
showResult: false,
|
||||||
@@ -60,6 +66,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
|
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
isBatchTranscribing: false,
|
isBatchTranscribing: false,
|
||||||
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
showToast: false,
|
showToast: false,
|
||||||
showResult: false,
|
showResult: false,
|
||||||
|
|||||||
6
src/types/electron.d.ts
vendored
6
src/types/electron.d.ts
vendored
@@ -865,6 +865,12 @@ export interface ExportProgress {
|
|||||||
exportedMessages?: number
|
exportedMessages?: number
|
||||||
estimatedTotalMessages?: number
|
estimatedTotalMessages?: number
|
||||||
writtenFiles?: number
|
writtenFiles?: number
|
||||||
|
mediaDoneFiles?: number
|
||||||
|
mediaCacheHitFiles?: number
|
||||||
|
mediaCacheMissFiles?: number
|
||||||
|
mediaCacheFillFiles?: number
|
||||||
|
mediaDedupReuseFiles?: number
|
||||||
|
mediaBytesWritten?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user