Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test/
# Build output
out
release
native/**/target/

# Database
*.db
Expand Down Expand Up @@ -77,4 +78,4 @@ wechat-research-site
weflow-web-offical
/Wedecrypt
/scripts/syncwcdb.py
/scripts/syncWedecrypt.py
/scripts/syncWedecrypt.py
134 changes: 129 additions & 5 deletions electron/exportWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface ExportWorkerConfig {
options?: any
taskId?: string
dbPath?: string
accountDir?: string
decryptKey?: string
myWxid?: string
imageXorKey?: unknown
Expand Down Expand Up @@ -137,10 +138,31 @@ if (config.userDataPath) {
}
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'

function isExportControlInterruption(error: unknown): boolean {
const text = error instanceof Error
? `${(error as Error & { code?: string }).code || ''} ${error.message}`
: String(error || '')
return (
text.includes('WEFLOW_EXPORT_STOP_REQUESTED') ||
text.includes('WEFLOW_EXPORT_PAUSE_REQUESTED') ||
text.includes('导出任务已停止') ||
text.includes('导出任务已暂停')
)
}

async function run() {
const [{ wcdbService }, { exportService }] = await Promise.all([
const [
{ wcdbService },
{ exportService },
{ chooseExportEngine, getRustExportDisabledReason },
{ exportSessionsWithRustStreaming },
{ canUseTypeScriptStreamingExport, exportSessionsWithTypeScriptStreaming }
] = await Promise.all([
import('./services/wcdbService'),
import('./services/exportService')
import('./services/exportService'),
import('./services/export/exportEngineRouter'),
import('./services/export/rustStreamingExporter'),
import('./services/export/typescriptStreamingExporter')
])

wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
Expand Down Expand Up @@ -188,13 +210,115 @@ async function run() {
taskControl
)
} else {
result = await exportService.exportSessions(
const options = config.options || { format: 'json' }
const requestedEngine = String(options.engine || 'auto')
const resolvedEngine = chooseExportEngine(options)
const rustDisabledReason = getRustExportDisabledReason(options)
const rustProgress = (progress: any) => onProgress({
...progress,
exportEngine: 'rust',
exportEngineLabel: 'Rust'
})
let typeScriptEngineLabel = requestedEngine === 'typescript'
? 'TypeScript · 手动指定'
: rustDisabledReason
? `TypeScript · Rust未启用:${rustDisabledReason}`
: 'TypeScript'
const typeScriptProgress = (progress: any) => onProgress({
...progress,
exportEngine: 'typescript',
exportEngineLabel: typeScriptEngineLabel
})
const runTypeScriptExport = async () => exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
onProgress,
options,
typeScriptProgress,
taskControl
)
const runTypeScriptStreamingExport = async () => exportSessionsWithTypeScriptStreaming({
source: wcdbService,
sessionIds: Array.isArray(config.sessionIds) ? config.sessionIds : [],
outputDir: String(config.outputDir || ''),
options,
accountDir: String(config.accountDir || config.dbPath || ''),
decryptKey: String(config.decryptKey || ''),
cleanedMyWxid: String(config.myWxid || ''),
onProgress: typeScriptProgress,
control: taskControl
})
const runRustStreamingExport = async () => exportSessionsWithRustStreaming({
source: wcdbService,
sessionIds: Array.isArray(config.sessionIds) ? config.sessionIds : [],
outputDir: String(config.outputDir || ''),
options,
accountDir: String(config.accountDir || config.dbPath || ''),
decryptKey: String(config.decryptKey || ''),
cleanedMyWxid: String(config.myWxid || ''),
resourcesPath: String(config.resourcesPath || ''),
onProgress: rustProgress,
control: taskControl
})

if (resolvedEngine === 'rust') {
try {
onProgress({
current: 0,
total: 100,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseLabel: 'Rust 引擎准备导出',
exportEngine: 'rust',
exportEngineLabel: 'Rust'
})
result = await runRustStreamingExport()
} catch (error) {
if (requestedEngine === 'rust' || isExportControlInterruption(error)) {
throw error
}
const fallbackReason = error instanceof Error ? error.message : String(error)
typeScriptEngineLabel = `TypeScript · Rust回退:${fallbackReason.slice(0, 160)}`
console.warn(`[exportWorker] Rust exporter unavailable, falling back to TypeScript: ${fallbackReason}`)
onProgress({
current: 0,
total: 100,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseLabel: `Rust 引擎不可用,已回退 TypeScript:${fallbackReason.slice(0, 160)}`,
exportEngine: 'typescript',
exportEngineLabel: typeScriptEngineLabel
})
result = await runTypeScriptExport()
}
} else {
if (requestedEngine === 'auto' && rustDisabledReason) {
onProgress({
current: 0,
total: 100,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseLabel: `TypeScript 引擎导出(Rust 未启用:${rustDisabledReason})`
})
}
if (requestedEngine === 'typescript') {
onProgress({
current: 0,
total: 100,
currentSession: '',
currentSessionId: '',
phase: 'preparing',
phaseLabel: canUseTypeScriptStreamingExport(options)
? 'TypeScript 流式引擎准备导出'
: 'TypeScript 引擎准备导出'
})
}
result = requestedEngine === 'typescript' && canUseTypeScriptStreamingExport(options)
? await runTypeScriptStreamingExport()
: await runTypeScriptExport()
}
}

flushProgress()
Expand Down
2 changes: 2 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3191,6 +3191,7 @@ function registerIpcHandlers() {
const dbPath = String(cfg.get('dbPath') || '').trim()
const decryptKey = String(cfg.get('decryptKey') || '').trim()
const myWxid = String(cfg.getMyWxidCleaned() || '').trim()
const accountDir = cfg.getAccountDir(dbPath, myWxid) || ''
const imageKeys = cfg.getImageKeysForCurrentWxid()
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
Expand All @@ -3207,6 +3208,7 @@ function registerIpcHandlers() {
options,
taskId,
dbPath,
accountDir,
decryptKey,
myWxid,
imageXorKey: imageKeys.xorKey,
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
total: number
currentSession: string
currentSessionId?: string
exportEngine?: 'rust' | 'typescript'
exportEngineLabel?: string
phase: string
phaseProgress?: number
phaseTotal?: number
Expand Down
30 changes: 6 additions & 24 deletions electron/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js'
import {
cleanSystemMessageContent,
extractReadableSystemMessageText as extractReadableSystemMessageTextValue
} from './systemMessageFormatter'

export interface ChatSession {
username: string
Expand Down Expand Up @@ -6812,33 +6816,11 @@ class ChatService {
}

private cleanSystemMessage(content: string): string {
if (!content) return '[系统消息]'

const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content)))
const readableSysmsg = this.extractReadableSystemMessageText(normalized)
if (readableSysmsg) {
return readableSysmsg
}

// 移除 XML 声明
let cleaned = normalized.replace(/<\?xml[^?]*\?>/gi, '')
// 移除所有 XML/HTML 标签
cleaned = cleaned.replace(/<[^>]+>/g, '')
// 移除尾部的数字(如撤回消息后的时间戳)
cleaned = cleaned.replace(/\d+\s*$/, '')
// 清理多余空白
cleaned = this.stripSenderPrefix(cleaned).replace(/\s+/g, ' ').trim()
return cleaned || '[系统消息]'
return cleanSystemMessageContent(content)
}

private extractReadableSystemMessageText(content: string): string {
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
const source = sysmsgMatch?.[1] || content
const text =
this.extractXmlValue(source, 'plain') ||
this.extractXmlValue(source, 'text') ||
''
return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
return extractReadableSystemMessageTextValue(content)
}

private stripSenderPrefix(content: string): string {
Expand Down
2 changes: 2 additions & 0 deletions electron/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportEngine: 'auto' | 'rust' | 'typescript'
analyticsExcludedUsernames: string[]

// 安全相关
Expand Down Expand Up @@ -198,6 +199,7 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportEngine: 'auto',
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
Expand Down
42 changes: 42 additions & 0 deletions electron/services/export/exportEngineRouter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { chooseExportEngine, getRustExportDisabledReason, isRustSupportedFormat, isTextOnlyExport } from './exportEngineRouter'

describe('export engine routing', () => {
it('routes auto text-only supported formats to rust', () => {
expect(chooseExportEngine({ format: 'txt' })).toBe('rust')
expect(chooseExportEngine({ format: 'html', contentType: 'text' })).toBe('rust')
expect(chooseExportEngine({ format: 'json' })).toBe('rust')
expect(chooseExportEngine({ format: 'weclone' })).toBe('rust')
expect(chooseExportEngine({ format: 'chatlab-jsonl' })).toBe('rust')
})

it('routes unsupported formats and media-heavy options to typescript in auto mode', () => {
expect(chooseExportEngine({ format: 'excel' })).toBe('typescript')
expect(chooseExportEngine({ format: 'txt', exportMedia: true, exportImages: true })).toBe('typescript')
expect(chooseExportEngine({ format: 'json', exportMedia: true, exportImages: true })).toBe('typescript')
expect(chooseExportEngine({ format: 'html', exportAvatars: true })).toBe('typescript')
expect(chooseExportEngine({ format: 'txt', exportVoiceAsText: true })).toBe('typescript')
expect(chooseExportEngine({ format: 'txt', contentType: 'image' })).toBe('typescript')
})

it('honors explicit engine requests', () => {
expect(chooseExportEngine({ format: 'excel', engine: 'rust' })).toBe('rust')
expect(chooseExportEngine({ format: 'txt', engine: 'typescript' })).toBe('typescript')
expect(chooseExportEngine({ format: 'txt', engine: 'auto' })).toBe('rust')
})

it('exposes narrow predicates for bridge fallback decisions', () => {
expect(isRustSupportedFormat('txt')).toBe(true)
expect(isRustSupportedFormat('json')).toBe(true)
expect(isRustSupportedFormat('chatlab')).toBe(false)
expect(isTextOnlyExport({ format: 'txt' })).toBe(true)
expect(isTextOnlyExport({ format: 'txt', exportFiles: true })).toBe(false)
})

it('explains why rust is disabled', () => {
expect(getRustExportDisabledReason({ format: 'chatlab' })).toContain('暂不支持 Rust')
expect(getRustExportDisabledReason({ format: 'json', exportMedia: true })).toBe('媒体导出已开启')
expect(getRustExportDisabledReason({ format: 'txt', exportAvatars: true })).toBe('头像导出已开启')
expect(getRustExportDisabledReason({ format: 'txt' })).toBeNull()
})
})
91 changes: 91 additions & 0 deletions electron/services/export/exportEngineRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export type ExportEngine = 'auto' | 'typescript' | 'rust'

export type ExportFormat =
| 'chatlab'
| 'chatlab-jsonl'
| 'json'
| 'arkme-json'
| 'html'
| 'txt'
| 'excel'
| 'weclone'
| 'sql'

export interface ExportEngineOptions {
format: ExportFormat | string
engine?: ExportEngine
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' | string
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportFiles?: boolean
exportVoiceAsText?: boolean
}

const RUST_SUPPORTED_FORMATS = new Set<ExportFormat>([
'json',
'txt',
'html',
'weclone',
'chatlab-jsonl'
])

const TYPESCRIPT_STREAMING_SUPPORTED_FORMATS = new Set<ExportFormat>([
'txt',
'html',
'chatlab-jsonl'
])

export type ResolvedExportEngine = Exclude<ExportEngine, 'auto'>

export function isRustSupportedFormat(format: ExportFormat | string): boolean {
return RUST_SUPPORTED_FORMATS.has(format)
}

export function isTypeScriptStreamingSupportedFormat(format: ExportFormat | string): boolean {
return TYPESCRIPT_STREAMING_SUPPORTED_FORMATS.has(format)
}

export function isTextOnlyExport(options: ExportEngineOptions): boolean {
if (options.contentType && options.contentType !== 'text') return false
if (options.exportMedia === true) return false
if (options.exportAvatars === true) return false
if (options.exportImages === true) return false
if (options.exportVoices === true) return false
if (options.exportVideos === true) return false
if (options.exportEmojis === true) return false
if (options.exportFiles === true) return false
if (options.exportVoiceAsText === true) return false
return true
}

export function canUseRustExportEngine(options: ExportEngineOptions): boolean {
return isRustSupportedFormat(options.format) && isTextOnlyExport(options)
}

export function canUseTypeScriptStreamingEngine(options: ExportEngineOptions): boolean {
return isTypeScriptStreamingSupportedFormat(options.format) && isTextOnlyExport(options)
}

export function getRustExportDisabledReason(options: ExportEngineOptions): string | null {
if (!isRustSupportedFormat(options.format)) return `格式 ${options.format} 暂不支持 Rust`
if (options.contentType && options.contentType !== 'text') return `内容类型 ${options.contentType} 不是纯文本`
if (options.exportMedia === true) return '媒体导出已开启'
if (options.exportAvatars === true) return '头像导出已开启'
if (options.exportImages === true) return '图片导出已开启'
if (options.exportVoices === true) return '语音导出已开启'
if (options.exportVideos === true) return '视频导出已开启'
if (options.exportEmojis === true) return '表情导出已开启'
if (options.exportFiles === true) return '文件导出已开启'
if (options.exportVoiceAsText === true) return '语音转文字已开启'
return null
}

export function chooseExportEngine(options: ExportEngineOptions): ResolvedExportEngine {
if (options.engine === 'rust') return 'rust'
if (options.engine === 'typescript') return 'typescript'
return canUseRustExportEngine(options) ? 'rust' : 'typescript'
}
Loading