Compare commits

...

33 Commits

Author SHA1 Message Date
cc
4a57a503f5 #881 2026-05-01 14:46:14 +08:00
cc
d53ddb0ba7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-30 00:00:04 +08:00
cc
1fc710ccef 修复底层配置服务混乱的问题 2026-04-29 23:59:56 +08:00
H3CoF6
82200e5fd7 Merge pull request #872 from H3CoF6/feat/image_hook
自动下载大图功能
2026-04-29 08:34:39 +08:00
H3CoF6
bdf285062f 优化下载会话选择页面 2026-04-29 08:25:45 +08:00
H3CoF6
b1807b21e7 feat: 选择会话的前端界面 2026-04-29 08:07:16 +08:00
H3CoF6
32feac7d5e chore: update dll for impl whitelist 2026-04-29 07:26:43 +08:00
H3CoF6
d2e59db123 fix: 修复AUR下载安装包并尝试提交的bug 2026-04-29 04:56:01 +08:00
H3CoF6
d27cef6358 优化前端显示和错误提醒 2026-04-29 04:38:25 +08:00
H3CoF6
1f0b2613bf feat(image): 新增自动下载大图选项(win32 x64)
Co-authored-by: NineBird <CavanasD@users.noreply.github.com>
2026-04-29 04:05:48 +08:00
H3CoF6
9c7ed1729a chore: add win32 dll + readme 2026-04-29 02:44:01 +08:00
cc
52f58f6288 Merge pull request #868 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration & streamline insight prompts & and optimize UI animations in the Insights section
2026-04-28 22:26:35 +08:00
Jason
dfe0186267 Add files via upload 2026-04-28 14:00:26 +08:00
Jason
fd9b7c4546 Merge pull request #38 from Jasonzhu1207/fix/insight-prompt-animation-polish
fix(insight): trim prompt noise and smooth settings animation
2026-04-28 13:40:23 +08:00
Jason
9f9ad337ab fix(insight): trim prompt noise and smooth settings animation 2026-04-28 13:35:11 +08:00
Jason
c596d24083 Merge branch 'hicccc77:main' into main 2026-04-28 13:00:15 +08:00
Jason
6cfc38c33a Merge pull request #37 from Jasonzhu1207/fix/insight-settings-ui-polish
fix(settings): polish insight context controls
2026-04-28 12:59:57 +08:00
Jason
13cede13f9 fix(settings): polish insight context controls 2026-04-28 12:55:46 +08:00
xuncha
440c1f166a Merge pull request #865 from hicccc77/dev
Dev
2026-04-28 12:50:05 +08:00
Jason
106d19fc6c Merge pull request #36 from Jasonzhu1207/fix/release-upload-assets
fix(release): upload assets after packaging
2026-04-28 12:12:51 +08:00
Jason
60a4011539 fix(release): upload assets after packaging 2026-04-28 12:05:33 +08:00
Jason
fd97920fb2 Merge pull request #35 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration
2026-04-28 00:17:41 +08:00
Jason
55a7ce7b66 feat(insight): add moments context gating and prompt integration 2026-04-28 00:14:05 +08:00
cc
7469337aeb fix: support service runtime fallbacks 2026-04-27 23:08:39 +08:00
cc
338d0e2f20 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-26 18:46:59 +08:00
cc
a86a51c30c #849 以及导出中媒体相关问题修复 2026-04-26 18:46:56 +08:00
cc
043332d297 Merge pull request #851 from hicccc77/main
Dev
2026-04-26 14:55:18 +08:00
cc
608f74a3f9 Merge pull request #850 from hicccc77/dev
Dev
2026-04-26 14:54:36 +08:00
cc
551d05fe2e Update MAC-KEY-FAQ with login instructions 2026-04-26 14:53:53 +08:00
cc
c9317f76a3 Merge pull request #846 from BeiChen-CN/codex/export-pause-cancel
fix(export): 添加朋友圈导出控制按钮
2026-04-26 14:48:48 +08:00
cc
ffd533d865 Merge pull request #848 from hicccc77/dev
更新封面图
2026-04-26 12:11:12 +08:00
cc
1976edc483 更新封面图 2026-04-26 12:09:56 +08:00
姜北尘
bb42a7c0b2 fix(export): 修复朋友圈导出控制按钮 2026-04-25 23:54:32 +08:00
30 changed files with 2110 additions and 351 deletions

View File

@@ -350,6 +350,8 @@ jobs:
updpkgsums: true
assets: |
resources/installer/linux/weflow.desktop
resources/installer/linux/icon.png
resources/installer/linux/.gitignore
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6

View File

@@ -3,17 +3,15 @@
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
<p align="center">
<img src="app.png" alt="WeFlow 应用预览" width="90%">
<img src="app.jpg" alt="WeFlow 应用预览" width="90%">
</p>
<p align="center">
<!-- 第一行修复样式 -->
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
<br><br>
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
</p>

BIN
app.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

BIN
app.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -20,9 +20,10 @@
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
### 常见报错与应对方法
@@ -50,4 +51,4 @@
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。

View File

@@ -1,14 +1,18 @@
import { parentPort, workerData } from 'worker_threads'
import type { ExportOptions } from './services/exportService'
interface ExportWorkerConfig {
sessionIds: string[]
outputDir: string
options: ExportOptions
mode?: 'sessions' | 'single' | 'contacts'
sessionIds?: string[]
sessionId?: string
outputDir?: string
outputPath?: string
options?: any
taskId?: string
dbPath?: string
decryptKey?: string
myWxid?: string
imageXorKey?: unknown
imageAesKey?: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
@@ -20,6 +24,93 @@ const controlState = {
stopRequested: false
}
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
const CREATED_PATH_BATCH_LIMIT = 256
const PROGRESS_POST_INTERVAL_MS = 180
let queuedCreatedFiles: string[] = []
let queuedCreatedDirs: string[] = []
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
let pendingProgress: any = null
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
let lastProgressPostedAt = 0
function flushCreatedPaths() {
if (createdPathFlushTimer) {
clearTimeout(createdPathFlushTimer)
createdPathFlushTimer = null
}
const filePaths = queuedCreatedFiles
const dirPaths = queuedCreatedDirs
queuedCreatedFiles = []
queuedCreatedDirs = []
if (!parentPort) return
if (filePaths.length > 0) {
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
}
if (dirPaths.length > 0) {
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
}
}
function scheduleCreatedPathFlush() {
if (createdPathFlushTimer) return
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
}
function queueCreatedFile(filePath: string) {
const normalized = String(filePath || '').trim()
if (!normalized) return
queuedCreatedFiles.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function queueCreatedDir(dirPath: string) {
const normalized = String(dirPath || '').trim()
if (!normalized) return
queuedCreatedDirs.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function flushProgress() {
if (!pendingProgress) return
if (progressPostTimer) {
clearTimeout(progressPostTimer)
progressPostTimer = null
}
parentPort?.postMessage({
type: 'export:progress',
data: pendingProgress
})
pendingProgress = null
lastProgressPostedAt = Date.now()
}
function queueProgress(progress: any) {
pendingProgress = progress
if (progress?.phase === 'complete') {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressPostedAt
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
flushProgress()
return
}
if (progressPostTimer) return
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
}
parentPort?.on('message', (message: any) => {
if (!message || typeof message.type !== 'string') return
if (message.type === 'export:pause') {
@@ -57,32 +148,49 @@ async function run() {
exportService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
myWxid: config.myWxid,
imageXorKey: config.imageXorKey,
imageAesKey: config.imageAesKey
})
const result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
(progress) => {
parentPort?.postMessage({
type: 'export:progress',
data: progress
})
},
config.taskId
? {
shouldPause: () => controlState.pauseRequested,
shouldStop: () => controlState.stopRequested,
recordCreatedFile: (filePath: string) => {
parentPort?.postMessage({ type: 'export:createdFile', filePath })
},
recordCreatedDir: (dirPath: string) => {
parentPort?.postMessage({ type: 'export:createdDir', dirPath })
}
}
: undefined
)
const onProgress = (progress: any) => queueProgress(progress)
const taskControl = config.taskId
? {
shouldPause: () => controlState.pauseRequested,
shouldStop: () => controlState.stopRequested,
recordCreatedFile: queueCreatedFile,
recordCreatedDir: queueCreatedDir
}
: undefined
let result: any
if (config.mode === 'contacts') {
const { contactExportService } = await import('./services/contactExportService')
result = await contactExportService.exportContacts(
String(config.outputDir || ''),
config.options || {}
)
} else if (config.mode === 'single') {
result = await exportService.exportSessionToChatLab(
String(config.sessionId || '').trim(),
String(config.outputPath || '').trim(),
config.options || { format: 'chatlab' },
onProgress,
taskControl
)
} else {
result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
onProgress,
taskControl
)
}
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
@@ -91,6 +199,8 @@ async function run() {
}
run().catch((error) => {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:error',
error: String(error)

View File

@@ -23,7 +23,6 @@ import { KeyServiceMac } from './services/keyServiceMac'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService'
@@ -35,6 +34,7 @@ import { insightService } from './services/insightService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService'
import { backupService } from './services/backupService'
import { imageDownloadService } from './services/imageDownloadService'
// 配置自动更新
autoUpdater.autoDownload = false
@@ -3046,7 +3046,7 @@ function registerIpcHandlers() {
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
const taskId = normalizeExportTaskId(controlOptions?.taskId)
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined
if (taskId) exportTaskControlService.createControl(taskId, outputDir)
if (taskId) activeExportTasks.add(taskId)
const PROGRESS_FORWARD_INTERVAL_MS = 180
let pendingProgress: ExportProgress | null = null
@@ -3091,17 +3091,13 @@ function registerIpcHandlers() {
queueProgress(progress)
}
const runMainFallback = async (reason: string) => {
console.warn(`[fallback-export-main] ${reason}`)
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
}
const cfg = configService || new ConfigService()
configService = cfg
const logEnabled = cfg.get('logEnabled')
const dbPath = String(cfg.get('dbPath') || '').trim()
const decryptKey = String(cfg.get('decryptKey') || '').trim()
const myWxid = String(cfg.get('myWxid') || '').trim()
const imageKeys = cfg.getImageKeysForCurrentWxid()
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
@@ -3119,6 +3115,8 @@ function registerIpcHandlers() {
dbPath,
decryptKey,
myWxid,
imageXorKey: imageKeys.xorKey,
imageAesKey: imageKeys.aesKey,
resourcesPath,
userDataPath,
logEnabled
@@ -3155,6 +3153,20 @@ function registerIpcHandlers() {
onProgress(msg.data as ExportProgress)
return
}
if (msg && msg.type === 'export:createdFiles' && taskId) {
const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
for (const filePath of filePaths) {
exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
}
return
}
if (msg && msg.type === 'export:createdDirs' && taskId) {
const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
for (const dirPath of dirPaths) {
exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
}
return
}
if (msg && msg.type === 'export:createdFile' && taskId) {
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
return
@@ -3191,7 +3203,21 @@ function registerIpcHandlers() {
const result = await runWorker()
return await finalizeExportTaskControlResult(taskId, result)
} catch (error) {
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker] ${errorMessage}`)
const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
const failedSessionErrors: Record<string, string> = {}
for (const sessionId of normalizedSessionIds) {
failedSessionErrors[sessionId] = errorMessage
}
const result = {
success: false,
successCount: 0,
failCount: normalizedSessionIds.length,
failedSessionIds: normalizedSessionIds,
failedSessionErrors,
error: `导出 Worker 执行失败: ${errorMessage}`
}
return await finalizeExportTaskControlResult(taskId, result)
} finally {
if (taskId) activeExportTasks.delete(taskId)
@@ -3203,12 +3229,136 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
const cfg = configService || new ConfigService()
configService = cfg
const imageKeys = cfg.getImageKeysForCurrentWxid()
const workerPath = join(__dirname, 'exportWorker.js')
try {
return await new Promise<any>((resolve) => {
const worker = new Worker(workerPath, {
workerData: {
mode: 'single',
sessionId,
outputPath,
options,
dbPath: String(cfg.get('dbPath') || '').trim(),
decryptKey: String(cfg.get('decryptKey') || '').trim(),
myWxid: String(cfg.get('myWxid') || '').trim(),
imageXorKey: imageKeys.xorKey,
imageAesKey: imageKeys.aesKey,
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
}
})
let settled = false
const finalize = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const fail = (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-single] ${errorMessage}`)
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:progress') {
if (!event.sender.isDestroyed()) {
event.sender.send('export:progress', msg.data)
}
return
}
if (msg && msg.type === 'export:result') {
finalize(msg.data)
return
}
if (msg && msg.type === 'export:error') {
fail(String(msg.error || '导出 Worker 执行失败'))
}
})
worker.on('error', fail)
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalize({ success: false, error: '导出 Worker 未返回结果' })
} else {
fail(`导出 Worker 异常退出: ${code}`)
}
})
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-single] ${errorMessage}`)
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
}
})
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
return contactExportService.exportContacts(outputDir, options)
const cfg = configService || new ConfigService()
configService = cfg
const workerPath = join(__dirname, 'exportWorker.js')
try {
return await new Promise<any>((resolve) => {
const worker = new Worker(workerPath, {
workerData: {
mode: 'contacts',
outputDir,
options,
dbPath: String(cfg.get('dbPath') || '').trim(),
decryptKey: String(cfg.get('decryptKey') || '').trim(),
myWxid: String(cfg.get('myWxid') || '').trim(),
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
userDataPath: app.getPath('userData'),
logEnabled: cfg.get('logEnabled')
}
})
let settled = false
const finalize = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const fail = (error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-contacts] ${errorMessage}`)
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:result') {
finalize(msg.data)
return
}
if (msg && msg.type === 'export:error') {
fail(String(msg.error || '导出 Worker 执行失败'))
}
})
worker.on('error', fail)
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalize({ success: false, error: '导出 Worker 未返回结果' })
} else {
fail(`导出 Worker 异常退出: ${code}`)
}
})
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`[export-worker-contacts] ${errorMessage}`)
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
}
})
// 数据分析相关
@@ -3805,6 +3955,19 @@ function registerIpcHandlers() {
}
})
// 自动下载原图
ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => {
return await imageDownloadService.startAutoDownload(whitelist || [])
})
ipcMain.handle('image:stopAutoDownload', async () => {
await imageDownloadService.stopAutoDownload()
return { success: true }
})
ipcMain.handle('image:getAutoDownloadStatus', async () => {
return await imageDownloadService.getStatus()
})
}
// 主窗口引用
@@ -3932,6 +4095,13 @@ app.whenReady().then(async () => {
// 注册 IPC 处理器
updateSplashProgress(28, '正在初始化...')
registerIpcHandlers()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json)
@@ -4103,6 +4273,8 @@ const shutdownAppServices = async (): Promise<void> => {
}, 5000)
forceExitTimer.unref()
try { await cloudControlService.stop() } catch {}
// 停止自动下载服务
try { await imageDownloadService.stopAutoDownload() } catch {}
// 停止 chatService内部会关闭 cursor 与 DB避免退出阶段仍触发监控回调
try { chatService.close() } catch {}
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出

View File

@@ -365,7 +365,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
}
},
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
},
// 视频
@@ -374,6 +377,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
process: {
platform: process.platform,
arch: process.arch
},
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),

View File

@@ -85,7 +85,13 @@ interface ConfigSchema {
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
@@ -110,6 +116,8 @@ interface ConfigSchema {
aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
@@ -205,6 +213,9 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
@@ -222,7 +233,9 @@ export class ConfigService {
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
}
const storeOptions: any = {

View File

@@ -112,6 +112,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean
txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session'
exportWriteLayout?: 'A' | 'B' | 'C'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
@@ -271,7 +272,7 @@ async function parallelLimit<T, R>(
class ExportService {
private configService: ConfigService
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
private inlineEmojiCache: LRUCache<string, string>
private htmlStyleCache: string | null = null
@@ -287,6 +288,8 @@ class ExportService {
private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>()
private activeChatImagePipelineCount = 0
private chatImagePipelineWaiters: Array<() => void> = []
private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
@@ -320,8 +323,22 @@ class ExportService {
return error
}
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
this.runtimeConfig = config
imageDecryptService.setRuntimeConfig({
dbPath: config?.dbPath,
myWxid: config?.myWxid,
imageXorKey: config?.imageXorKey,
imageAesKey: config?.imageAesKey
})
}
private getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
private getConfiguredMyWxid(): string {
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
}
private normalizeSessionIds(sessionIds: string[]): string[] {
@@ -354,6 +371,33 @@ class ExportService {
return { start, end }
}
private normalizeMaxFileSizeMb(value: unknown): number | undefined {
const raw = Number(value)
if (!Number.isFinite(raw) || raw <= 0) return undefined
return Math.floor(raw)
}
private normalizeExportOptionsForRun(options: ExportOptions): ExportOptions {
const normalizedDateRange = this.normalizeExportDateRange(options.dateRange)
const normalizedMaxFileSizeMb = this.normalizeMaxFileSizeMb(options.maxFileSizeMb)
const normalizedWriteLayout = this.resolveExportWriteLayout(options)
return {
...options,
dateRange: normalizedDateRange,
maxFileSizeMb: normalizedMaxFileSizeMb,
exportWriteLayout: normalizedWriteLayout
}
}
private resolveExportWriteLayout(options?: Pick<ExportOptions, 'exportWriteLayout'> | null): 'A' | 'B' | 'C' {
const optionLayout = options?.exportWriteLayout
if (optionLayout === 'A' || optionLayout === 'B' || optionLayout === 'C') return optionLayout
const rawWriteLayout = this.configService.get('exportWriteLayout')
return rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'B'
}
private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string {
const normalized = this.normalizeExportDateRange(dateRange)
if (!normalized) return 'all'
@@ -370,8 +414,8 @@ class ExportService {
const normalizedIds = this.normalizeSessionIds(sessionIds).sort()
const senderToken = String(options.senderUsername || '').trim()
const dateToken = this.getExportStatsDateRangeToken(options.dateRange)
const dbPath = String(this.configService.get('dbPath') || '').trim()
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim()
const dbPath = this.getConfiguredDbPath()
const wxidToken = String(cleanedWxid || this.cleanAccountDirName(this.getConfiguredMyWxid()) || '').trim()
return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}`
}
@@ -712,6 +756,20 @@ class ExportService {
this.mediaRunMissingImageKeys.clear()
}
private async runWithChatImagePipelineLimit<T>(fn: () => Promise<T>): Promise<T> {
while (this.activeChatImagePipelineCount >= 2) {
await new Promise<void>((resolve) => this.chatImagePipelineWaiters.push(resolve))
}
this.activeChatImagePipelineCount += 1
try {
return await fn()
} finally {
this.activeChatImagePipelineCount = Math.max(0, this.activeChatImagePipelineCount - 1)
const next = this.chatImagePipelineWaiters.shift()
if (next) next()
}
}
private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
const stats = this.mediaExportTelemetry
if (!stats) return {}
@@ -1577,8 +1635,8 @@ class ExportService {
}
private resolveStrictEmoticonDbPath(): string | null {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const dbPath = this.getConfiguredDbPath()
const rawWxid = this.getConfiguredMyWxid()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
const token = `${dbPath}::${rawWxid}::${cleanedWxid}`
if (token === this.emoticonDbPathCacheToken) {
@@ -1823,8 +1881,8 @@ class ExportService {
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
const wxid = this.getConfiguredMyWxid()
const dbPath = this.getConfiguredDbPath()
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
@@ -4092,44 +4150,79 @@ class ExportService {
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
if (!imageMd5 && !imageDatName) return null
return this.runWithChatImagePipelineLimit(async () => {
const pickResolvedImagePath = (result: any): string | null => {
if (!result?.success) return null
const resolved = String(result.localPath || '').trim()
return resolved || null
}
const decryptResult = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
const resolveCachedPath = async (candidateMd5?: string, candidateDatName?: string): Promise<string | null> => {
const cachedResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5: candidateMd5,
imageDatName: candidateDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: true,
suppressEvents: true
})
return pickResolvedImagePath(cachedResult)
}
const cachedPath = await resolveCachedPath(imageMd5, imageDatName)
if (cachedPath) {
return cachedPath
}
const decryptResult = await imageDecryptService.decryptImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: false,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true
})
const decryptedPath = pickResolvedImagePath(decryptResult)
if (decryptedPath) return decryptedPath
const localId = Number(msg?.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
const fallback = await chatService.getImageData(sessionId, String(localId))
if (fallback.success && fallback.data) {
const buffer = Buffer.from(fallback.data, 'base64')
const mime = this.detectMimeType(buffer) || 'image/jpeg'
return `data:${mime};base64,${fallback.data}`
}
}
if (decryptResult.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
}
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: true,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
return thumbResult.localPath
}
return null
})
if (decryptResult.success && decryptResult.localPath) {
return decryptResult.localPath
}
if (decryptResult.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5 || ''}, imageDatName=${imageDatName || ''}, error=${decryptResult.error || '未知'}`)
}
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
preferFilePath: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
return thumbResult.localPath
}
return null
}
// 使用消息对象中已提取的字段,先尝试快速导出。
@@ -4235,11 +4328,10 @@ class ExportService {
const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
} else {
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
if (md5Pattern.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase()
if (md5Pattern.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
@@ -4487,16 +4579,89 @@ class ExportService {
*/
private extractImageDatName(content: string): string | undefined {
if (!content) return undefined
// 尝试从 cdnthumburl 或其他字段提取
const urlMatch = /cdnthumburl[^>]*>([^<]+)/i.exec(content)
if (urlMatch) {
const urlParts = urlMatch[1].split('/')
const last = urlParts[urlParts.length - 1]
if (last && last.includes('_')) {
return last.split('_')[0]
}
const candidate =
this.extractXmlValue(content, 'imgname') ||
this.extractXmlValue(content, 'cdnmidimgurl') ||
this.extractXmlValue(content, 'cdnthumburl') ||
this.extractXmlAttribute(content, 'img', 'imgname') ||
this.extractXmlAttribute(content, 'img', 'cdnmidimgurl') ||
this.extractXmlAttribute(content, 'img', 'cdnthumburl')
return this.normalizeImageDatNameToken(candidate)
}
private normalizeImageDatNameToken(value: unknown): string | undefined {
let text = String(value ?? '').trim()
if (!text) return undefined
text = text.replace(/&amp;/g, '&')
try {
if (text.includes('%')) text = decodeURIComponent(text)
} catch { }
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
if (datLike?.[1]) return datLike[1].toLowerCase()
const base = text
.split(/[?#]/, 1)[0]
.replace(/^.*[\\/]/, '')
.replace(/\.(?:t\.)?dat$/i, '')
.trim()
if (!base) return undefined
const cdnToken = base.includes('_') ? base.split('_')[0] : base
const exact = /^([a-fA-F0-9]{16,64})$/.exec(cdnToken)
if (exact?.[1]) return exact[1].toLowerCase()
const preferred32 = /([a-fA-F0-9]{32})(?![a-fA-F0-9])/i.exec(cdnToken)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-fA-F0-9]{16,64})(?![a-fA-F0-9])/i.exec(cdnToken)
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromPackedRaw(raw: unknown): string | undefined {
const buffer = this.decodePackedInfoBuffer(raw)
if (!buffer || buffer.length === 0) return undefined
const printable: number[] = []
for (const byte of buffer) {
printable.push(byte >= 0x20 && byte <= 0x7e ? byte : 0x20)
}
return undefined
const text = Buffer.from(printable).toString('utf-8')
const datLike = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/i.exec(text)
if (datLike?.[1]) return datLike[1].toLowerCase()
const fallback = /([0-9a-fA-F]{16,})/.exec(text)
return fallback?.[1]?.toLowerCase()
}
private extractImageDatNameFromRow(row: Record<string, any>, content?: string): string | undefined {
const byColumn = this.normalizeImageDatNameToken(this.getRowField(row, [
'image_path',
'imagePath',
'image_dat_name',
'imageDatName',
'img_path',
'imgPath',
'img_name',
'imgName'
]))
if (byColumn) return byColumn
const packedRaw = this.getRowField(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
const byPacked = this.extractImageDatNameFromPackedRaw(packedRaw)
if (byPacked) return byPacked
return this.extractImageDatName(content || '')
}
/**
@@ -4699,8 +4864,8 @@ class ExportService {
}
private resolveFileAttachmentSearchRoots(): FileAttachmentSearchRoot[] {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const rawWxid = String(this.configService.get('myWxid') || '').trim()
const dbPath = this.getConfiguredDbPath()
const rawWxid = this.getConfiguredMyWxid()
const cleanedWxid = this.cleanAccountDirName(rawWxid)
if (!dbPath) return []
@@ -5050,10 +5215,7 @@ class ExportService {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
const outputDir = path.dirname(outputPath)
const rawWriteLayout = this.configService.get('exportWriteLayout')
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'A'
const writeLayout = this.resolveExportWriteLayout(options)
// A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories.
if (writeLayout === 'A' && path.basename(outputDir) === 'texts') {
return {
@@ -5229,7 +5391,7 @@ class ExportService {
: await wcdbService.openMessageCursor(
sessionId,
batchSize,
true,
false,
beginTime,
endTime
)
@@ -5417,7 +5579,7 @@ class ExportService {
if (collectMode === 'full' || collectMode === 'media-fast') {
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
imageDatName = localType === 3 ? this.extractImageDatNameFromRow(row, content) : undefined
videoMd5 = this.extractVideoFileNameFromRow(row, content)
xmlType = rowFileHints.xmlType
fileName = rowFileHints.fileName
@@ -5439,7 +5601,7 @@ class ExportService {
if (localType === 3 && content) {
// 图片消息
imageMd5 = imageMd5 || this.extractImageMd5(content)
imageDatName = imageDatName || this.extractImageDatName(content)
imageDatName = imageDatName || this.extractImageDatNameFromRow(row, content)
} else if (localType === 43 && content) {
// 视频消息
videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content)
@@ -5587,9 +5749,51 @@ class ExportService {
}
}
if (rows.length > 1) {
rows.sort((a, b) => {
const timeDelta = (a.createTime || 0) - (b.createTime || 0)
if (timeDelta !== 0) return timeDelta
return (a.localId || 0) - (b.localId || 0)
})
}
return { rows, memberSet, firstTime, lastTime }
}
private async getRecentWcdbCursorLogSummary(sessionId: string): Promise<string | undefined> {
try {
const logResult = await wcdbService.getLogs()
if (!logResult.success || !Array.isArray(logResult.logs)) return undefined
const sid = String(sessionId || '').trim()
const interesting = logResult.logs
.filter((line) => {
const text = String(line || '')
if (sid && text.includes(sid)) return true
return text.includes('QueryMessageBatch') ||
text.includes('InitExportCursorHeap') ||
text.includes('cursor_init') ||
text.includes('fetch_message_batch') ||
text.includes('open_message_cursor')
})
.slice(-8)
if (interesting.length === 0) return undefined
return interesting.join(' | ')
} catch {
return undefined
}
}
private async buildNoMessagesError(
sessionId: string,
collected: { error?: string },
fallback = '该会话在指定时间范围内没有消息'
): Promise<string> {
if (collected.error) return collected.error
const nativeLogSummary = await this.getRecentWcdbCursorLogSummary(sessionId)
if (!nativeLogSummary) return fallback
return `${fallback}WCDB日志${nativeLogSummary}`
}
private async backfillMediaFieldsFromMessageDetail(
sessionId: string,
rows: any[],
@@ -5608,7 +5812,7 @@ class ExportService {
return !msg.xmlType || !msg.fileName || !msg.fileMd5 || !msg.fileSize || !msg.fileExt
}
if (!targetMediaTypes.has(msg.localType)) return false
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
if (msg.localType === 3) return !msg.imageMd5 || !msg.imageDatName
if (msg.localType === 47) return !msg.emojiMd5
if (msg.localType === 43) return !msg.videoMd5
return false
@@ -5639,7 +5843,7 @@ class ExportService {
if (msg.localType === 3) {
const imageMd5 = (String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) || '').toLowerCase()
const imageDatName = (String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) || '').toLowerCase()
const imageDatName = this.extractImageDatNameFromRow(row, content) || ''
if (imageMd5) msg.imageMd5 = imageMd5
if (imageDatName) msg.imageDatName = imageDatName
return
@@ -6111,7 +6315,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -6149,7 +6353,7 @@ class ExportService {
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control)
@@ -6649,7 +6853,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -6687,7 +6891,7 @@ class ExportService {
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -7380,7 +7584,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -7423,7 +7627,7 @@ class ExportService {
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -8264,7 +8468,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -8301,7 +8505,7 @@ class ExportService {
// 如果没有消息,不创建文件
if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -8662,7 +8866,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
@@ -8697,7 +8901,7 @@ class ExportService {
)
let totalMessages = collected.rows.length
if (totalMessages === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
@@ -9113,7 +9317,7 @@ class ExportService {
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const rawMyWxid = String(this.configService.get('myWxid') || '').trim()
const rawMyWxid = this.getConfiguredMyWxid()
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
@@ -9152,7 +9356,7 @@ class ExportService {
// 如果没有消息,不创建文件
if (collected.rows.length === 0) {
return { success: false, error: collected.error || '该会话在指定时间范围内没有消息' }
return { success: false, error: await this.buildNoMessagesError(sessionId, collected) }
}
const totalMessages = collected.rows.length
@@ -9948,6 +10152,7 @@ class ExportService {
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string>
error?: string
}> {
@@ -9955,6 +10160,7 @@ class ExportService {
let failCount = 0
const successSessionIds: string[] = []
const failedSessionIds: string[] = []
const failedSessionErrors: Record<string, string> = {}
const sessionOutputPaths: Record<string, string> = {}
const progressEmitter = this.createProgressEmitter(onProgress)
let attachMediaTelemetry = false
@@ -9972,9 +10178,10 @@ class ExportService {
}
this.resetMediaRuntimeState()
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
? { ...options, exportVoiceAsText: false }
: options
const normalizedOptions = this.normalizeExportOptionsForRun(options)
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(normalizedOptions)
? { ...normalizedOptions, exportVoiceAsText: false }
: normalizedOptions
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis || effectiveOptions.exportFiles)
@@ -9982,10 +10189,7 @@ class ExportService {
if (exportMediaEnabled) {
this.triggerMediaFileCacheCleanup()
}
const rawWriteLayout = this.configService.get('exportWriteLayout')
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
? rawWriteLayout
: 'A'
const writeLayout = this.resolveExportWriteLayout(effectiveOptions)
const exportBaseDir = writeLayout === 'A'
? path.join(outputDir, 'texts')
: outputDir
@@ -10020,7 +10224,6 @@ class ExportService {
const queue = [...sessionIds]
let pauseRequested = false
let stopRequested = false
const emptySessionIds = new Set<string>()
const sessionMessageCountHints = new Map<string, number>()
const sessionLatestTimestampHints = new Map<string, number>()
const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid)
@@ -10033,17 +10236,12 @@ class ExportService {
if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) {
sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp)))
}
if (snapshot.totalCount <= 0) {
emptySessionIds.add(sessionId)
}
}
}
const canUseSessionSnapshotHints = isTextContentBatchExport &&
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = !isTextContentBatchExport &&
this.isUnboundedDateRange(effectiveOptions.dateRange) &&
!String(effectiveOptions.senderUsername || '').trim()
const canFastSkipEmptySessions = false
const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints
const precheckSessionIds = canFastSkipEmptySessions
? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId))
@@ -10082,9 +10280,6 @@ class ExportService {
if (typeof count === 'number' && Number.isFinite(count) && count >= 0) {
sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count)))
}
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
emptySessionIds.add(batchSessionId)
}
}
}
@@ -10154,6 +10349,7 @@ class ExportService {
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
@@ -10166,6 +10362,7 @@ class ExportService {
pendingSessionIds: [...queue],
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
@@ -10177,46 +10374,6 @@ class ExportService {
const messageCountHint = sessionMessageCountHints.get(sessionId)
const latestTimestampHint = sessionLatestTimestampHints.get(sessionId)
if (
isTextContentBatchExport &&
typeof messageCountHint === 'number' &&
messageCountHint <= 0
) {
successCount++
successSessionIds.push(sessionId)
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '该会话没有消息,已跳过',
estimatedTotalMessages: 0,
exportedMessages: 0
}, { force: true })
return 'done'
}
if (emptySessionIds.has(sessionId)) {
successCount++
successSessionIds.push(sessionId)
activeSessionRatios.delete(sessionId)
completedCount++
emitProgress({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '该会话没有消息,已跳过',
estimatedTotalMessages: 0,
exportedMessages: 0
}, { force: true })
return 'done'
}
const sessionProgress = (progress: ExportProgress) => {
const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100
const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0
@@ -10339,6 +10496,7 @@ class ExportService {
} else {
failCount++
failedSessionIds.push(sessionId)
failedSessionErrors[sessionId] = result.error || '导出失败'
console.error(`导出 ${sessionId} 失败:`, result.error)
}
@@ -10433,6 +10591,7 @@ class ExportService {
pendingSessionIds,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
@@ -10445,6 +10604,7 @@ class ExportService {
pendingSessionIds,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths
}
}
@@ -10458,7 +10618,20 @@ class ExportService {
}, { force: true })
progressEmitter.flush()
return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths }
const allFailed = successCount === 0 && failCount > 0
const failureSummary = allFailed
? Object.values(failedSessionErrors).slice(0, 3).join('') || '所有会话导出失败'
: undefined
return {
success: !allFailed,
successCount,
failCount,
successSessionIds,
failedSessionIds,
failedSessionErrors,
sessionOutputPaths,
error: failureSummary
}
} catch (e) {
progressEmitter.flush()
return { success: false, successCount, failCount, error: String(e) }

View File

@@ -81,6 +81,7 @@ export class ImageDecryptService {
private pending = new Map<string, Promise<DecryptResult>>()
private updateFlags = new Map<string, boolean>()
private nativeLogged = false
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200
private readonly accountDirCache = new Map<string, string>()
@@ -99,6 +100,32 @@ export class ImageDecryptService {
return this.shouldEmitImageEvents(payload)
}
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
this.runtimeConfig = config
}
private getConfiguredDbPath(): string {
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
}
private getConfiguredMyWxid(): string {
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
}
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
if (hasRuntimeXorKey || runtimeAesKey) {
const fallback = this.configService.getImageKeysForCurrentWxid()
return {
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
aesKey: runtimeAesKey || fallback.aesKey
}
}
return this.configService.getImageKeysForCurrentWxid()
}
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
@@ -266,8 +293,8 @@ export class ImageDecryptService {
)
if (normalizedList.length === 0) return
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const wxid = this.getConfiguredMyWxid()
const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) return
const accountDir = this.resolveAccountDir(dbPath, wxid)
@@ -294,8 +321,8 @@ export class ImageDecryptService {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const wxid = this.getConfiguredMyWxid()
const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
@@ -404,7 +431,7 @@ export class ImageDecryptService {
}
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const imageKeys = this.getConfiguredImageKeys()
const xorKeyRaw = imageKeys.xorKey
// 支持十六进制格式(如 0x53和十进制格式
let xorKey: number
@@ -427,7 +454,7 @@ export class ImageDecryptService {
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
const aesKeyForNative = aesKeyText || undefined
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!nativeResult) {
@@ -527,8 +554,8 @@ export class ImageDecryptService {
}
private resolveCurrentAccountDir(): string | null {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const wxid = this.getConfiguredMyWxid()
const dbPath = this.getConfiguredDbPath()
if (!wxid || !dbPath) return null
return this.resolveAccountDir(dbPath, wxid)
}
@@ -1551,7 +1578,117 @@ export class ImageDecryptService {
})
}
}
return result
if (result) return result
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
if (fallback) {
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
}
return fallback
}
private tryDecryptDatWithJs(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
try {
const encrypted = readFileSync(datPath)
const directExt = this.detectImageExtension(encrypted)
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
const candidates: Buffer[] = []
const aesKeyText = String(aesKey || '').trim()
const datVersion = this.getDatVersion(encrypted)
if (datVersion === 2 && aesKeyText.length >= 16) {
try {
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
} catch { }
}
if (datVersion !== 2) {
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
}
for (const candidate of candidates) {
const ext = this.detectImageExtension(candidate)
if (ext) return { data: candidate, ext, isWxgf: false }
}
} catch (error) {
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
}
return null
}
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
const output = Buffer.allocUnsafe(data.length)
for (let i = 0; i < data.length; i += 1) {
output[i] = data[i] ^ xorKey
}
return output
}
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
if (data.length < 0x0f) {
throw new Error('dat file too small')
}
const header = data.subarray(0, 0x0f)
const payload = data.subarray(0x0f)
const aesSize = this.readInt32LeSafe(header, 6)
const xorSize = this.readInt32LeSafe(header, 10)
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
const aesData = payload.subarray(0, alignedAesSize)
let plainAes = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
}
const remaining = payload.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
let rawData = Buffer.alloc(0)
let decodedXor = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) throw new Error('invalid raw size')
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
decodedXor = Buffer.allocUnsafe(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
decodedXor[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
}
return Buffer.concat([plainAes, rawData, decodedXor])
}
private getDatVersion(data: Buffer): number {
if (data.length < 6) return 0
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
if (data.subarray(0, 6).equals(sigV1)) return 1
if (data.subarray(0, 6).equals(sigV2)) return 2
return 0
}
private readInt32LeSafe(buffer: Buffer, offset: number): number {
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
}
private strictRemovePkcs7Padding(data: Buffer): Buffer {
if (data.length === 0) throw new Error('empty decrypted data')
const pad = data[data.length - 1]
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
for (let i = data.length - pad; i < data.length; i += 1) {
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
}
return data.subarray(0, data.length - pad)
}
private detectImageExtension(buffer: Buffer): string | null {

View File

@@ -0,0 +1,203 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
// import { ConfigService } from './config'
const execFileAsync = promisify(execFile)
export class ImageDownloadService {
private static instance: ImageDownloadService
private koffi: any = null
private lib: any = null
private initialized = false
private initImgHelper: any = null
private uninstallImgHelper: any = null
private getImgHelperError: any = null
private currentPid: number | null = null
private pollTimer: NodeJS.Timeout | null = null
private isHooked = false
private lastWhitelist: string[] = []
static getInstance(): ImageDownloadService {
if (!ImageDownloadService.instance) {
ImageDownloadService.instance = new ImageDownloadService()
}
return ImageDownloadService.instance
}
private constructor() {
}
private async ensureInitialized(): Promise<boolean> {
if (this.initialized) return true
if (process.platform !== 'win32' || process.arch !== 'x64') return false
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
this.initialized = true
return true
} catch (error) {
console.error('[ImageDownloadService] failed to initialize:', error)
return false
}
}
private getDllPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
} else {
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0]
}
private async findMainWeChatPid(): Promise<number | null> {
try {
const script = `
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`;
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
if (!stdout || !stdout.trim()) return null
let processes = JSON.parse(stdout.trim())
if (!Array.isArray(processes)) processes = [processes]
const target = processes
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
return target ? target.ProcessId : null;
} catch (e) {
return null
}
}
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
if (!await this.ensureInitialized()) {
return { success: false, error: '核心组件初始化失败' }
}
if (this.isHooked) {
await this.unhook()
}
this.lastWhitelist = whitelist
if (!this.pollTimer) {
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
}
return await this.checkAndHook(whitelist, true)
}
async stopAutoDownload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
await this.unhook()
}
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
const pid = await this.findMainWeChatPid()
if (!pid) {
if (this.isHooked) {
console.log('[ImageDownloadService] WeChat exited, unhooking')
await this.unhook()
}
return { success: true, error: '等待微信启动' }
}
if (this.isHooked && this.currentPid === pid) {
return { success: true }
}
if (this.isHooked && this.currentPid !== pid) {
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
await this.unhook()
}
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
try {
let whitelistBuffer: Buffer | null = null;
if (typeof whitelist === 'string') {
if (whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist, 'utf8');
}
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
}
const success = this.initImgHelper(pid, whitelistBuffer)
if (success) {
this.isHooked = true
this.currentPid = pid
console.log('[ImageDownloadService] hook successful')
return { success: true }
} else {
const err = this.getImgHelperError()
console.error(`[ImageDownloadService] hook failed: ${err}`)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: err || 'Hook 失败' }
}
} catch (e: any) {
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: `调用异常: ${e.message || String(e)}` }
}
}
private async unhook() {
if (this.isHooked && this.uninstallImgHelper) {
try {
this.uninstallImgHelper()
} catch (e) {
console.error('[ImageDownloadService] uninstall failed:', e)
}
}
this.isHooked = false
this.currentPid = null
}
async getStatus() {
return {
isHooked: this.isHooked,
pid: this.currentPid,
supported: process.platform === 'win32' && process.arch === 'x64'
}
}
}
export const imageDownloadService = ImageDownloadService.getInstance()

View File

@@ -10,7 +10,7 @@
* 设计原则:
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
* - 所有失败静默处理,不影响主流程
* - 当日触发记录sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
* - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
*/
import https from 'https'
@@ -21,6 +21,7 @@ import { URL } from 'url'
import { app, Notification } from 'electron'
import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -52,6 +53,9 @@ const INSIGHT_CONFIG_KEYS = new Set([
'aiModelApiMaxTokens',
'aiInsightFilterMode',
'aiInsightFilterList',
'aiInsightAllowMomentsContext',
'aiInsightMomentsContextCount',
'aiInsightMomentsBindings',
'aiInsightAllowSocialContext',
'aiInsightSocialContextCount',
'aiInsightWeiboCookie',
@@ -445,7 +449,7 @@ class InsightService {
try {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }]
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
insightDebugSection(
'INFO',
'AI 测试连接请求',
@@ -823,26 +827,13 @@ ${topMentionText}
}
/**
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt
* 记录成功推送的见解,用于设置页展示今日触发统计
*/
private recordTrigger(sessionId: string): string[] {
private recordTrigger(sessionId: string): void {
this.resetIfNewDay()
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
existing.timestamps.push(Date.now())
this.todayTriggers.set(sessionId, existing)
return existing.timestamps.map(formatTimestamp)
}
/**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模型全局上下文。
*/
private getTodayTotalTriggerCount(): number {
this.resetIfNewDay()
let total = 0
for (const record of this.todayTriggers.values()) {
total += record.timestamps.length
}
return total
}
private formatWeiboTimestamp(raw: string): string {
@@ -853,12 +844,66 @@ ${topMentionText}
return new Date(parsed).toLocaleString('zh-CN')
}
private formatMomentsTimestamp(raw: unknown): string {
const numeric = Number(raw)
if (!Number.isFinite(numeric) || numeric <= 0) {
return ''
}
const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
return new Date(ms).toLocaleString('zh-CN')
}
private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string {
const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim()
if (contentDesc) return contentDesc
const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim()
if (linkTitle) return `[链接] ${linkTitle}`
return ''
}
private async getMomentsContextSection(sessionId: string): Promise<string> {
const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true
if (!allowMomentsContext) return ''
const bindings =
(this.config.get('aiInsightMomentsBindings') as Record<string, { enabled?: boolean }> | undefined) || {}
const isEnabledForSession = bindings[sessionId]?.enabled === true
if (!isEnabledForSession) return ''
const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5)
const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5))
try {
const result = await snsService.getTimeline(momentsCount, 0, [sessionId])
const posts = result.success && Array.isArray(result.timeline) ? result.timeline : []
if (posts.length === 0) return ''
const lines = posts
.map((post) => {
const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown })
if (!text) return ''
const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text
const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime)
return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}`
})
.filter(Boolean) as string[]
if (lines.length === 0) return ''
insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`)
return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`)
return ''
}
}
private async getSocialContextSection(sessionId: string): Promise<string> {
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
if (!allowSocialContext) return ''
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
const hasCookie = rawCookie.length > 0
const bindings =
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
@@ -879,10 +924,7 @@ ${topMentionText}
return `[微博 ${time}] ${text}`
})
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
const riskHint = hasCookie
? ''
: '\n提示未配置微博 Cookie使用移动端公开接口抓取可能因平台风控导致获取失败或内容较少。'
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}`
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
return ''
@@ -1118,10 +1160,6 @@ ${topMentionText}
// ── 构建 prompt ────────────────────────────────────────────────────────────
// 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId)
const totalTodayTriggers = this.getTodayTotalTriggerCount()
let contextSection = ''
if (allowContext) {
try {
@@ -1136,6 +1174,7 @@ ${topMentionText}
}
}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
@@ -1151,25 +1190,12 @@ ${topMentionText}
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
// 这样 provider 端Anthropic/OpenAI能最大化命中 prompt cache降低费用
const triggerDesc =
triggerReason === 'silence'
? `你已经 ${silentDays} 天没有和「${resolvedDisplayName}」聊天了。`
: `你最近和「${resolvedDisplayName}」有新的聊天动态。`
const todayStatsDesc =
sessionTriggerTimes.length > 1
? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
: `今天你还没有针对「${resolvedDisplayName}」发出过见解。`
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
const userPromptBase = [
`触发原因:${triggerDesc}`,
`时间统计:${todayStatsDesc}`,
`全局统计:${globalStatsDesc}`,
triggerReason === 'silence' && silentDays
? `${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解≤80字'
].filter(Boolean).join('\n\n')
@@ -1189,7 +1215,7 @@ ${topMentionText}
`接口地址:${endpoint}`,
`模型:${model}`,
`Max Tokens${maxTokens}`,
`触发原因${triggerReason}`,
`触发类型${triggerReason}`,
`上下文开关:${allowContext ? '开启' : '关闭'}`,
`上下文条数:${contextCount}`,
'',
@@ -1253,6 +1279,7 @@ ${topMentionText}
}
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
this.recordTrigger(sessionId)
} catch (e) {
insightDebugSection(
'ERROR',

View File

@@ -0,0 +1 @@
> 目前只适配了x64 win32平台其它平台同样原理但是代码还没写

Binary file not shown.

6
resources/installer/linux/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.tar.gz
*.tar.xz
*.zip
src/
pkg/
weflow-*/

View File

@@ -11,8 +11,7 @@
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
max-height: calc(100vh - 80px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
@@ -21,12 +20,14 @@
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h4 {
margin: 0;
@@ -35,6 +36,26 @@
}
}
.export-date-range-dialog-content {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
@@ -439,6 +460,7 @@
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.export-date-range-dialog-btn {

View File

@@ -565,6 +565,7 @@ export function ExportDateRangeDialog({
</button>
</div>
<div className="export-date-range-dialog-content">
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
@@ -728,6 +729,7 @@ export function ExportDateRangeDialog({
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>

View File

@@ -5192,6 +5192,7 @@ function ExportPage() {
exportConcurrency: sourceOptions.exportConcurrency,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
exportWriteLayout: writeLayout,
sessionNameWithTypePrefix,
dateRange: sourceOptions.useAllTime
? null
@@ -6008,9 +6009,10 @@ function ExportPage() {
}
return {
...task.template.optionTemplate,
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
dateRange
}
}, [])
}, [writeLayout])
const enqueueAutomationTask = useCallback((
task: ExportAutomationTask,

View File

@@ -915,6 +915,31 @@
color: var(--text-secondary);
}
.insight-collapsible-setting {
max-height: 0;
opacity: 0;
overflow: hidden;
transform: translate3d(0, -4px, 0);
contain: layout paint;
will-change: max-height, opacity, transform;
transition: max-height 0.2s ease, opacity 0.18s ease, transform 0.2s ease;
&.expanded {
max-height: 128px;
opacity: 1;
transform: translate3d(0, 0, 0);
}
&.collapsed {
pointer-events: none;
}
}
.insight-collapsible-setting-inner {
padding-top: 2px;
backface-visibility: hidden;
}
/* Premium Switch Style */
.switch {
position: relative;
@@ -3616,17 +3641,35 @@
}
&.insight-social-tab {
--insight-moments-column-width: 76px;
--insight-social-column-width: minmax(220px, 300px);
--insight-status-column-width: 82px;
--insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width);
.anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto;
grid-template-columns: var(--insight-social-list-grid);
gap: 14px;
.insight-moments-column-title {
display: flex;
justify-content: center;
color: var(--text-tertiary);
}
.insight-social-column-title {
min-width: 0;
color: var(--text-tertiary);
}
.anti-revoke-status-column-title {
justify-self: end;
color: var(--text-tertiary);
}
}
.anti-revoke-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto;
grid-template-columns: var(--insight-social-list-grid);
align-items: center;
gap: 14px;
}
@@ -3635,6 +3678,67 @@
min-width: 0;
}
.insight-moments-cell {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
}
.insight-moments-toggle {
position: relative;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
input[type='checkbox'] {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.check-indicator {
width: 100%;
height: 100%;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
color: var(--on-primary, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.16s ease;
svg {
opacity: 0;
transform: scale(0.75);
transition: opacity 0.16s ease, transform 0.16s ease;
}
}
input[type='checkbox']:checked + .check-indicator {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
svg {
opacity: 1;
transform: scale(1);
}
}
input[type='checkbox']:focus-visible + .check-indicator {
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
outline-offset: 1px;
}
}
.insight-social-binding-cell {
min-width: 0;
display: grid;
@@ -3653,7 +3757,7 @@
.binding-platform-chip {
flex-shrink: 0;
border-radius: 999px;
padding: 2px 8px;
padding: 2px 7px;
font-size: 11px;
color: var(--text-secondary);
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
@@ -3663,7 +3767,7 @@
.insight-social-binding-input {
width: 100%;
min-width: 0;
height: 30px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
@@ -3706,9 +3810,10 @@
}
.anti-revoke-row-status {
justify-self: flex-end;
justify-self: end;
align-items: flex-end;
max-width: none;
min-width: 0;
}
}
@@ -3752,6 +3857,7 @@
.anti-revoke-list-header {
grid-template-columns: minmax(0, 1fr) auto;
.insight-moments-column-title,
.insight-social-column-title {
display: none;
}
@@ -3763,11 +3869,16 @@
flex-direction: column;
}
.insight-moments-cell,
.insight-social-binding-cell,
.anti-revoke-row-status {
width: 100%;
}
.insight-moments-cell {
justify-content: flex-start;
}
.insight-social-binding-cell {
grid-template-columns: 1fr;
}

View File

@@ -32,6 +32,7 @@ type SettingsTab =
| 'aiCommon'
| 'insight'
| 'aiFootprint'
| 'autoDownload'
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
@@ -39,6 +40,7 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
{ id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic },
{ id: 'autoDownload', label: '自动下载', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 },
@@ -47,6 +49,13 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
{ id: 'about', label: '关于', icon: Info }
]
const filteredTabs = tabs.filter(tab => {
if (tab.id === 'autoDownload') {
return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64'
}
return true
})
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
{ id: 'aiCommon', label: '基础配置' },
{ id: 'insight', label: 'AI 见解' },
@@ -149,6 +158,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
const [logEnabled, setLogEnabled] = useState(false)
const [autoDownloadHighRes, setAutoDownloadHighRes] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
const [whisperModelDir, setWhisperModelDir] = useState('')
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
@@ -284,6 +294,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200)
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
const [aiInsightMomentsContextCount, setAiInsightMomentsContextCount] = useState(5)
const [aiInsightMomentsBindings, setAiInsightMomentsBindings] = useState<Record<string, configService.AiInsightMomentsBinding>>({})
const [isTestingInsight, setIsTestingInsight] = useState(false)
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
@@ -315,6 +328,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
// 自动下载图片
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
const [autoDownloadSelectedIds, setAutoDownloadSelectedIds] = useState<Set<string>>(new Set())
const [autoDownloadSearchKeyword, setAutoDownloadSearchKeyword] = useState('')
// 检查 Hello 可用性
useEffect(() => {
setHelloAvailable(isWindows)
@@ -526,9 +544,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setWordCloudExcludeWords(savedExcludeWords)
setExcludeWordsInput(savedExcludeWords.join('\n'))
const savedAutoDownloadHighRes = await configService.getAutoDownloadHighRes()
const savedAutoDownloadWhitelist = await configService.getAutoDownloadWhitelist()
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false)
setAutoDownloadHighRes(savedAutoDownloadHighRes)
setAutoDownloadSelectedIds(new Set(savedAutoDownloadWhitelist))
// 如果语言列表为空,保存默认值
@@ -549,6 +570,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightAllowMomentsContext = await configService.getAiInsightAllowMomentsContext()
const savedAiInsightMomentsContextCount = await configService.getAiInsightMomentsContextCount()
const savedAiInsightMomentsBindings = await configService.getAiInsightMomentsBindings()
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
@@ -573,6 +597,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightAllowMomentsContext(savedAiInsightAllowMomentsContext)
setAiInsightMomentsContextCount(savedAiInsightMomentsContextCount)
setAiInsightMomentsBindings(savedAiInsightMomentsBindings)
setAiInsightFilterMode(savedAiInsightFilterMode)
setAiInsightFilterList(new Set(savedAiInsightFilterList))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
@@ -685,6 +712,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
void refreshWhisperStatus(whisperModelDir)
}, [whisperModelDir])
useEffect(() => {
if (activeTab === 'autoDownload') {
fetchAutoDownloadStatus()
let interval: ReturnType<typeof setInterval> | undefined
if (autoDownloadHighRes) {
interval = setInterval(fetchAutoDownloadStatus, 2000)
}
return () => {
if (interval) clearInterval(interval)
}
}
}, [activeTab, autoDownloadHighRes])
const getErrorMessage = (error: any): string => {
const raw = typeof error?.message === 'string' ? error.message : String(error ?? '')
const normalized = raw.replace(/^Error:\s*/i, '').trim()
@@ -1013,11 +1055,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
useEffect(() => {
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
if (activeTab !== 'antiRevoke' && activeTab !== 'insight' && activeTab !== 'autoDownload') return
let canceled = false
;(async () => {
try {
if (activeTab === 'antiRevoke') {
if (activeTab === 'antiRevoke' || activeTab === 'autoDownload') {
await ensureAntiRevokeSessionsLoaded()
} else {
await ensureChatSessionsLoaded()
@@ -1579,6 +1621,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const fetchAutoDownloadStatus = async () => {
try {
const status = await (window as any).electronAPI.image.getAutoDownloadStatus()
setAutoDownloadStatus(status)
} catch (error) {
console.error('获取自动下载状态失败:', error)
}
}
const renderAppearanceTab = () => (
<div className="tab-content">
<div className="theme-mode-toggle">
@@ -3081,6 +3132,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
})
}
const isMomentsEnabledForSession = (sessionId: string): boolean => {
return aiInsightMomentsBindings[sessionId]?.enabled === true
}
const handleToggleMomentsBinding = async (sessionId: string, enabled: boolean) => {
const nextBindings = { ...aiInsightMomentsBindings }
if (enabled) {
nextBindings[sessionId] = {
enabled: true,
updatedAt: Date.now()
}
} else {
delete nextBindings[sessionId]
}
setAiInsightMomentsBindings(nextBindings)
await configService.setAiInsightMomentsBindings(nextBindings)
}
const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => {
const draftUid = getWeiboBindingDraftValue(sessionId)
setWeiboBindingLoadingSessionId(sessionId)
@@ -3274,7 +3343,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint">
N AI
<br />
<strong></strong>AI
<strong></strong>
<br />
<strong></strong> API
</span>
@@ -3295,27 +3364,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
{aiInsightAllowContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
<div className={`insight-collapsible-setting ${aiInsightAllowContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
disabled={!aiInsightAllowContext}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
)}
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightAllowMomentsContext ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightAllowMomentsContext}
onChange={async (e) => {
const val = e.target.checked
setAiInsightAllowMomentsContext(val)
await configService.setAiInsightAllowMomentsContext(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className={`insight-collapsible-setting ${aiInsightAllowMomentsContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowMomentsContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightMomentsContextCount}
min={1}
max={20}
disabled={!aiInsightAllowMomentsContext}
onChange={(e) => {
const val = Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 5))
setAiInsightMomentsContextCount(val)
scheduleConfigSave('aiInsightMomentsContextCount', () => configService.setAiInsightMomentsContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
</div>
<div className="divider" />
@@ -3354,29 +3475,32 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)}
</div>
{aiInsightAllowSocialContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
<br />
<strong> 5</strong>
</span>
<input
type="number"
className="field-input"
value={aiInsightSocialContextCount}
min={1}
max={5}
onChange={(e) => {
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
setAiInsightSocialContextCount(val)
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
}}
style={{ width: 100 }}
/>
<div className={`insight-collapsible-setting ${aiInsightAllowSocialContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowSocialContext}>
<div className="insight-collapsible-setting-inner">
<div className="form-group">
<label></label>
<span className="form-hint">
<br />
<strong> 5</strong>
</span>
<input
type="number"
className="field-input"
value={aiInsightSocialContextCount}
min={1}
max={5}
disabled={!aiInsightAllowSocialContext}
onChange={(e) => {
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
setAiInsightSocialContextCount(val)
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
</div>
)}
</div>
<div className="divider" />
{/* 自定义 System Prompt */}
@@ -3652,11 +3776,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span className="insight-moments-column-title"></span>
<span className="insight-social-column-title"></span>
<span></span>
<span className="anti-revoke-status-column-title"></span>
</div>
{filteredSessions.map((session) => {
const isSelected = aiInsightFilterList.has(session.username)
const isPrivateSession = session.type === 'private'
const isMomentsEnabled = isMomentsEnabledForSession(session.username)
const weiboBinding = aiInsightWeiboBindings[session.username]
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
const isBindingLoading = weiboBindingLoadingSessionId === session.username
@@ -3695,8 +3822,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
</div>
</label>
<div className="insight-moments-cell">
{isPrivateSession ? (
<label className="insight-moments-toggle">
<input
type="checkbox"
checked={isMomentsEnabled}
onChange={(e) => { void handleToggleMomentsBinding(session.username, e.target.checked) }}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</label>
) : (
<span className="binding-feedback muted">-</span>
)}
</div>
<div className="insight-social-binding-cell">
{session.type === 'private' ? (
{isPrivateSession ? (
<>
<div className="insight-social-binding-input-wrap">
<span className="binding-platform-chip"></span>
@@ -3771,9 +3914,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-docs">
<div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br />
<strong></strong> 2 <br />
<strong></strong> 4 <br />
<strong></strong> AI AI <br />
<strong></strong> <br />
<strong></strong> API WeFlow
</p>
</div>
@@ -4557,6 +4700,203 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
)
const renderAutoDownloadTab = () => {
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = autoDownloadSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
const displayName = String(session.displayName || '').toLowerCase()
const username = String(session.username || '').toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
})
const filteredSessionIds = filteredSessions.map((session) => session.username)
const selectedCount = autoDownloadSelectedIds.size
const selectedInFilteredCount = filteredSessionIds.filter((id) => autoDownloadSelectedIds.has(id)).length
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
const isHooked = autoDownloadStatus?.isHooked
const persistWhitelist = (ids: Set<string>) => {
const whitelistArr = Array.from(ids)
configService.setAutoDownloadWhitelist(whitelistArr)
if (autoDownloadHighRes) {
const whitelistStr = whitelistArr.length > 0 ? (whitelistArr.join('\0') + '\0\0') : '';
(window as any).electronAPI.image.startAutoDownload(whitelistStr)
}
}
const toggleSelection = (id: string) => {
const next = new Set(autoDownloadSelectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const selectAllFiltered = () => {
const next = new Set(autoDownloadSelectedIds)
filteredSessionIds.forEach(id => next.add(id))
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
const clearSelection = () => {
const next = new Set<string>()
setAutoDownloadSelectedIds(next)
persistWhitelist(next)
}
return (
<div className="tab-content anti-revoke-tab">
{/* 顶部 Hero 区域保持不变 */}
<div className="anti-revoke-hero" style={{ background: 'linear-gradient(110deg, var(--bg-primary) 0%, rgba(245, 158, 11, 0.1) 100%)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
<div className="anti-revoke-hero-main">
<span className="updates-chip" style={{ color: '#f59e0b', background: 'rgba(245, 158, 11, 0.15)', width: 'fit-content' }}> (Test)</span>
<h2 style={{ marginTop: '8px' }}></h2>
<p></p>
</div>
<div className="anti-revoke-metrics">
<div className={`anti-revoke-metric ${isHooked ? 'is-installed' : 'is-pending'}`}>
<span className="label"></span>
<span className="value" style={{ fontSize: '14px' }}>
{isHooked ? '正在监控' : autoDownloadHighRes ? '等待连接' : '未启用'}
</span>
</div>
<div className="anti-revoke-metric">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索联系人或群聊..."
value={autoDownloadSearchKeyword}
onChange={(e) => setAutoDownloadSearchKeyword(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={filteredSessionIds.length === 0 || allFilteredSelected}>
</button>
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={selectedCount === 0}>
</button>
</div>
<div className="anti-revoke-btn-group" style={{ marginLeft: '12px', paddingLeft: '12px', borderLeft: '1px solid var(--border-color)' }}>
<label className="switch switch-md">
<input
type="checkbox"
checked={autoDownloadHighRes}
onChange={() => handleToggleAutoDownload(Array.from(autoDownloadSelectedIds))}
/>
<span className="switch-slider" />
</label>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
{autoDownloadHighRes ? '服务已开启' : '服务已关闭'}
</span>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span style={{ opacity: 0.6 }}></span>
</div>
</div>
</div>
<div className="anti-revoke-list">
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}</div>
) : (
filteredSessions.map((session) => {
const isSelected = autoDownloadSelectedIds.has(session.username)
return (
<div key={session.username} className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(session.username)}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar src={session.avatarUrl} name={session.displayName} size={30} />
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已监控' : '未开启'}
</span>
</div>
</div>
)
})
)}
</div>
{/* 风险提示部分保持不变 */}
<div className="api-warning-modal" style={{ width: '100%', border: '1px solid rgba(239, 68, 68, 0.2)', marginTop: '16px', background: 'rgba(239, 68, 68, 0.02)', animation: 'none', boxShadow: 'none', position: 'static' }}>
<div className="modal-header" style={{ border: 'none', padding: '12px 20px 0' }}>
<Lock size={16} color="#ef4444" />
<h3 style={{ fontSize: '13px', color: '#ef4444' }}></h3>
</div>
<div className="modal-body" style={{ fontSize: '12px', color: 'var(--text-secondary)', padding: '8px 20px 12px' }}>
Hook
</div>
</div>
</div>
)
}
const handleToggleAutoDownload = async (whitelist?: string[] | string) => {
const newVal = !autoDownloadHighRes
setAutoDownloadHighRes(newVal)
try {
if (newVal) {
let currentWhitelist: string[] | string = whitelist || Array.from(autoDownloadSelectedIds)
if (Array.isArray(currentWhitelist)) {
currentWhitelist = currentWhitelist.length > 0 ? (currentWhitelist.join('\0') + '\0\0') : ''
}
const result = await (window as any).electronAPI.image.startAutoDownload(currentWhitelist)
if (result && !result.success) {
// 如果底层明确返回了失败
throw new Error(result.error || '启动自动下载服务失败')
}
showMessage('自动下载已开启,正在尝试连接微信', true)
await fetchAutoDownloadStatus()
} else {
await (window as any).electronAPI.image.stopAutoDownload()
showMessage('自动下载已关闭', true)
setAutoDownloadStatus(null)
}
await configService.setAutoDownloadHighRes(newVal)
} catch (e: any) {
// 发生错误时,将开关拨回去
setAutoDownloadHighRes(!newVal)
showMessage(`操作失败: ${e.message || String(e)}`, false)
}
}
const renderUpdatesTab = () => {
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
@@ -4691,7 +5031,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.flatMap((tab) => {
{filteredTabs.flatMap((tab) => {
const row: React.ReactNode[] = [
<button
key={tab.id}
@@ -4749,6 +5089,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'aiCommon' && renderAiCommonTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
{activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}

View File

@@ -2015,6 +2015,7 @@
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
width: 480px;
max-width: 92vw;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
@@ -2062,6 +2063,9 @@
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
}
@@ -2729,6 +2733,54 @@
color: var(--text-tertiary);
text-align: center;
}
.export-progress-actions {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.export-progress-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 82px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover:not(:disabled) {
background: var(--hover-bg);
color: var(--text-primary);
}
&.primary {
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.danger {
border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color));
background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary));
color: #d9363e;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
}
.export-result {

View File

@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
import './SnsPage.scss'
import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem'
@@ -64,10 +64,42 @@ interface SnsOverviewStats {
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
type SnsExportTaskStatus = 'idle' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested'
interface SnsExportProgress {
current: number
total: number
status: string
}
interface SnsExportResult {
success: boolean
filePath?: string
postCount?: number
mediaCount?: number
paused?: boolean
stopped?: boolean
error?: string
}
interface SnsExportRequest {
taskId: string
outputDir: string
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportImages: boolean
exportLivePhotos: boolean
exportVideos: boolean
startTime?: number
endTime?: number
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
const createSnsExportTaskId = (): string => `sns-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
interface SnsCacheMigrationItem {
label: string
sourceDir: string
@@ -179,8 +211,9 @@ export default function SnsPage() {
() => createExportDateRangeSelectionFromPreset('all')
)
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
const [exportTaskStatus, setExportTaskStatus] = useState<SnsExportTaskStatus>('idle')
const [exportProgress, setExportProgress] = useState<SnsExportProgress | null>(null)
const [exportResult, setExportResult] = useState<SnsExportResult | null>(null)
const [refreshSpin, setRefreshSpin] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
@@ -211,6 +244,8 @@ export default function SnsPage() {
const snsUserPostCountsCacheScopeKeyRef = useRef('')
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
const activeContactsCountTaskIdRef = useRef<string | null>(null)
const activeExportTaskIdRef = useRef<string | null>(null)
const activeExportRequestRef = useRef<SnsExportRequest | null>(null)
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const pendingResetFeedRef = useRef(false)
const contactsLoadTokenRef = useRef(0)
@@ -465,7 +500,11 @@ export default function SnsPage() {
: overviewStatsStatus === 'loading' || contactsLoading
)
const canStartExport = Boolean(exportFolder) && !isExporting && (
const isExportLocked = isExporting || exportTaskStatus !== 'idle'
const canPauseExport = exportTaskStatus === 'running'
const canResumeExport = exportTaskStatus === 'paused' || exportTaskStatus === 'pause_requested'
const canCancelExport = exportTaskStatus !== 'idle'
const canStartExport = Boolean(exportFolder) && !isExportLocked && (
exportScope.kind === 'all' || exportScope.usernames.length > 0
)
@@ -772,14 +811,205 @@ export default function SnsPage() {
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
const clearActiveExportTask = useCallback(() => {
activeExportTaskIdRef.current = null
activeExportRequestRef.current = null
setExportTaskStatus('idle')
setIsExporting(false)
}, [])
const buildSnsExportRequest = useCallback((taskId: string): SnsExportRequest => ({
taskId,
outputDir: exportFolder,
format: exportFormat,
usernames: exportScope.kind === 'selected' ? [...exportScope.usernames] : undefined,
keyword: searchKeyword || undefined,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRangeSelection.useAllTime
? undefined
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
endTime: exportDateRangeSelection.useAllTime
? undefined
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
}), [
exportDateRangeSelection,
exportFolder,
exportFormat,
exportImages,
exportLivePhotos,
exportScope,
exportVideos,
searchKeyword
])
const runSnsExport = useCallback(async (request: SnsExportRequest, statusText = '准备导出...') => {
activeExportTaskIdRef.current = request.taskId
activeExportRequestRef.current = request
setIsExporting(true)
setExportTaskStatus('running')
setExportResult(null)
setExportProgress(prev => prev || { current: 0, total: 0, status: statusText })
let keepTaskActive = false
const removeProgress = window.electronAPI.sns.onExportProgress((progress: SnsExportProgress) => {
setExportProgress(progress)
})
try {
const result = await window.electronAPI.sns.exportTimeline(request)
if (!result.success) {
setExportResult(result)
return
}
if (result.paused) {
keepTaskActive = true
setExportTaskStatus('paused')
setExportProgress(prev => ({
current: Math.max(prev?.current || 0, result.postCount || 0),
total: Math.max(prev?.total || 0, result.postCount || 0),
status: '已暂停,可继续或取消'
}))
return
}
if (result.stopped) {
setExportResult(null)
setExportProgress(null)
setShowExportDialog(false)
return
}
setExportResult(result)
} catch (e: any) {
setExportResult({ success: false, error: e.message || String(e) })
} finally {
removeProgress()
setIsExporting(false)
if (!keepTaskActive) {
activeExportTaskIdRef.current = null
activeExportRequestRef.current = null
setExportTaskStatus('idle')
}
}
}, [])
const handleStartSnsExport = useCallback(() => {
if (!canStartExport) return
const request = buildSnsExportRequest(createSnsExportTaskId())
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
void runSnsExport(request)
}, [buildSnsExportRequest, canStartExport, runSnsExport])
const handlePauseSnsExport = useCallback(() => {
const taskId = activeExportTaskIdRef.current
if (!taskId || exportTaskStatus !== 'running') return
setExportTaskStatus('pause_requested')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: '暂停请求已发送,正在等待安全检查点'
}))
window.electronAPI.export.pauseTask(taskId).then(result => {
if (result.success) return
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: result.error || '暂停请求失败'
}))
}).catch(error => {
setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current)
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: String(error)
}))
})
}, [exportTaskStatus])
const handleResumeSnsExport = useCallback(() => {
const taskId = activeExportTaskIdRef.current
const request = activeExportRequestRef.current
if (!taskId || !request || (exportTaskStatus !== 'paused' && exportTaskStatus !== 'pause_requested')) return
setExportTaskStatus('running')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: '正在继续导出...'
}))
window.electronAPI.export.resumeTask(taskId).then(result => {
if (!result.success) {
setExportTaskStatus('paused')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: result.error || '继续任务失败'
}))
return
}
void runSnsExport(request, '正在继续导出...')
}).catch(error => {
setExportTaskStatus('paused')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: String(error)
}))
})
}, [exportTaskStatus, runSnsExport])
const handleCancelSnsExport = useCallback(() => {
const taskId = activeExportTaskIdRef.current
if (!taskId || exportTaskStatus === 'idle' || exportTaskStatus === 'cancel_requested') return
const shouldCloseAfterAck = exportTaskStatus === 'paused' || !isExporting
setExportTaskStatus('cancel_requested')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: '取消请求已发送,正在安全停止并清理'
}))
window.electronAPI.export.cancelTask(taskId).then(result => {
if (!result.success) {
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: result.error || '取消任务失败'
}))
return
}
if (shouldCloseAfterAck) {
clearActiveExportTask()
setExportResult(null)
setExportProgress(null)
setShowExportDialog(false)
}
}).catch(error => {
setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running')
setExportProgress(prev => ({
current: prev?.current || 0,
total: prev?.total || 0,
status: String(error)
}))
})
}, [clearActiveExportTask, exportTaskStatus, isExporting])
const openExportDialog = useCallback((scope: SnsExportScope) => {
if (isExportLocked) {
setShowExportDialog(true)
return
}
setExportScope(scope)
setExportResult(null)
setExportProgress(null)
clearActiveExportTask()
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
setIsExportDateRangeDialogOpen(false)
setShowExportDialog(true)
}, [])
}, [clearActiveExportTask, isExportLocked])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
@@ -2048,11 +2278,11 @@ export default function SnsPage() {
{/* 导出对话框 */}
{showExportDialog && (
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
<div className="modal-overlay" onClick={() => !isExportLocked && setShowExportDialog(false)}>
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
<div className="export-dialog-header">
<h3></h3>
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
<button className="close-btn" onClick={() => !isExportLocked && setShowExportDialog(false)} disabled={isExportLocked}>
<X size={20} />
</button>
</div>
@@ -2078,7 +2308,7 @@ export default function SnsPage() {
<button
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
onClick={() => setExportFormat('html')}
disabled={isExporting}
disabled={isExportLocked}
>
<FileText size={20} />
<span>HTML</span>
@@ -2087,7 +2317,7 @@ export default function SnsPage() {
<button
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
onClick={() => setExportFormat('json')}
disabled={isExporting}
disabled={isExportLocked}
>
<FileJson size={20} />
<span>JSON</span>
@@ -2096,7 +2326,7 @@ export default function SnsPage() {
<button
className={`format-option ${exportFormat === 'arkmejson' ? 'active' : ''}`}
onClick={() => setExportFormat('arkmejson')}
disabled={isExporting}
disabled={isExportLocked}
>
<FileJson size={20} />
<span>ArkmeJSON</span>
@@ -2124,7 +2354,7 @@ export default function SnsPage() {
setExportFolder(result.filePath)
}
}}
disabled={isExporting}
disabled={isExportLocked}
>
<FolderOpen size={16} />
</button>
@@ -2139,9 +2369,9 @@ export default function SnsPage() {
type="button"
className="time-range-trigger sns-export-time-range-trigger"
onClick={() => {
if (!isExporting) setIsExportDateRangeDialogOpen(true)
if (!isExportLocked) setIsExportDateRangeDialogOpen(true)
}}
disabled={isExporting}
disabled={isExportLocked}
>
<span>{exportDateRangeLabel}</span>
<span className="time-range-arrow">&gt;</span>
@@ -2161,7 +2391,7 @@ export default function SnsPage() {
type="checkbox"
checked={exportImages}
onChange={(e) => setExportImages(e.target.checked)}
disabled={isExporting}
disabled={isExportLocked}
/>
</label>
@@ -2170,7 +2400,7 @@ export default function SnsPage() {
type="checkbox"
checked={exportLivePhotos}
onChange={(e) => setExportLivePhotos(e.target.checked)}
disabled={isExporting}
disabled={isExportLocked}
/>
</label>
@@ -2179,7 +2409,7 @@ export default function SnsPage() {
type="checkbox"
checked={exportVideos}
onChange={(e) => setExportVideos(e.target.checked)}
disabled={isExporting}
disabled={isExportLocked}
/>
</label>
@@ -2194,7 +2424,7 @@ export default function SnsPage() {
</div>
{/* 进度条 */}
{isExporting && exportProgress && (
{isExportLocked && exportProgress && (
<div className="export-progress">
<div className="export-progress-bar">
<div
@@ -2203,6 +2433,39 @@ export default function SnsPage() {
/>
</div>
<span className="export-progress-text">{exportProgress.status}</span>
<div className="export-progress-actions">
{canPauseExport && (
<button
type="button"
className="export-progress-btn"
onClick={handlePauseSnsExport}
>
<Pause size={14} />
</button>
)}
{canResumeExport && (
<button
type="button"
className="export-progress-btn primary"
onClick={handleResumeSnsExport}
>
<Play size={14} />
</button>
)}
{canCancelExport && (
<button
type="button"
className="export-progress-btn danger"
onClick={handleCancelSnsExport}
disabled={exportTaskStatus === 'cancel_requested'}
>
<Square size={14} />
{exportTaskStatus === 'cancel_requested' ? '取消中' : '取消'}
</button>
)}
</div>
</div>
)}
@@ -2211,47 +2474,14 @@ export default function SnsPage() {
<button
className="export-cancel-btn"
onClick={() => setShowExportDialog(false)}
disabled={isExporting}
disabled={isExportLocked}
>
</button>
<button
className="export-start-btn"
disabled={!canStartExport}
onClick={async () => {
setIsExporting(true)
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
setExportResult(null)
// 监听进度
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
setExportProgress(progress)
})
try {
const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder,
format: exportFormat,
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
keyword: searchKeyword || undefined,
exportImages,
exportLivePhotos,
exportVideos,
startTime: exportDateRangeSelection.useAllTime
? undefined
: Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000),
endTime: exportDateRangeSelection.useAllTime
? undefined
: Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000)
})
setExportResult(result)
} catch (e: any) {
setExportResult({ success: false, error: e.message || String(e) })
} finally {
setIsExporting(false)
removeProgress()
}
}}
onClick={handleStartSnsExport}
>
{isExporting ? '导出中...' : '开始导出'}
</button>

View File

@@ -97,6 +97,9 @@ export const CONFIG_KEYS = {
AI_INSIGHT_API_MODEL: 'aiInsightApiModel',
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_ALLOW_MOMENTS_CONTEXT: 'aiInsightAllowMomentsContext',
AI_INSIGHT_MOMENTS_CONTEXT_COUNT: 'aiInsightMomentsContextCount',
AI_INSIGHT_MOMENTS_BINDINGS: 'aiInsightMomentsBindings',
AI_INSIGHT_ALLOW_SOCIAL_CONTEXT: 'aiInsightAllowSocialContext',
AI_INSIGHT_FILTER_MODE: 'aiInsightFilterMode',
AI_INSIGHT_FILTER_LIST: 'aiInsightFilterList',
@@ -116,7 +119,9 @@ export const CONFIG_KEYS = {
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled'
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
} as const
export interface WxidConfig {
@@ -132,6 +137,11 @@ export interface AiInsightWeiboBinding {
updatedAt: number
}
export interface AiInsightMomentsBinding {
enabled: boolean
updatedAt: number
}
export interface ExportDefaultMediaConfig {
images: boolean
videos: boolean
@@ -1922,6 +1932,24 @@ export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
}
export async function getAiInsightAllowMomentsContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT)
return value === true
}
export async function setAiInsightAllowMomentsContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT, allow)
}
export async function getAiInsightMomentsContextCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT)
return typeof value === 'number' && value > 0 ? value : 5
}
export async function setAiInsightMomentsContextCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT, count)
}
export async function getAiInsightAllowSocialContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT)
return value === true
@@ -2067,6 +2095,33 @@ export async function setAiInsightWeiboBindings(bindings: Record<string, AiInsig
await config.set(CONFIG_KEYS.AI_INSIGHT_WEIBO_BINDINGS, bindings)
}
const normalizeAiInsightMomentsBindings = (value: unknown): Record<string, AiInsightMomentsBinding> => {
if (!value || typeof value !== 'object') return {}
const result: Record<string, AiInsightMomentsBinding> = {}
for (const [sessionIdRaw, bindingRaw] of Object.entries(value as Record<string, unknown>)) {
const sessionId = String(sessionIdRaw || '').trim()
if (!sessionId) continue
if (!bindingRaw || typeof bindingRaw !== 'object') continue
const bindingObj = bindingRaw as { enabled?: unknown; updatedAt?: unknown }
if (bindingObj.enabled !== true) continue
const updatedAtRaw = Number(bindingObj.updatedAt)
result[sessionId] = {
enabled: true,
updatedAt: Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? Math.floor(updatedAtRaw) : Date.now()
}
}
return result
}
export async function getAiInsightMomentsBindings(): Promise<Record<string, AiInsightMomentsBinding>> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS)
return normalizeAiInsightMomentsBindings(value)
}
export async function setAiInsightMomentsBindings(bindings: Record<string, AiInsightMomentsBinding>): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS, normalizeAiInsightMomentsBindings(bindings))
}
export async function getAiFootprintEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
return value === true
@@ -2094,3 +2149,22 @@ export async function setAiInsightDebugLogEnabled(enabled: boolean): Promise<voi
await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled)
}
export async function getAutoDownloadHighRes(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES)
return value === true
}
export async function setAutoDownloadHighRes(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES, enabled)
}
export async function getAutoDownloadWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST)
return Array.isArray(value) ? value : []
}
export async function setAutoDownloadWhitelist(list: string[]): Promise<void> {
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST, normalized)
}

View File

@@ -1101,6 +1101,7 @@ export interface ElectronAPI {
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string>
error?: string
}>
@@ -1269,6 +1270,7 @@ export interface ExportOptions {
txtColumns?: string[]
fileNamingMode?: 'classic' | 'date-range'
sessionLayout?: 'shared' | 'per-session'
exportWriteLayout?: 'A' | 'B' | 'C'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number

View File

@@ -8,6 +8,77 @@ const handleElectronOnStart = (options: { reload: () => void }) => {
options.reload()
}
const exportWorkerElectronShimPlugin = () => {
const virtualId = 'virtual:weflow-export-worker-electron'
const resolvedVirtualId = `\0${virtualId}`
return {
name: 'weflow-export-worker-electron-shim',
enforce: 'pre' as const,
resolveId(id: string) {
if (id === virtualId) return resolvedVirtualId
return null
},
load(id: string) {
if (id !== resolvedVirtualId) return null
return `
import { homedir, tmpdir } from 'os'
import { join } from 'path'
const workerUserDataPath = () => String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const appDataPath = () => {
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support')
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
}
const getPath = (name) => {
if (name === 'userData') return workerUserDataPath() || join(appDataPath(), 'WeFlow')
if (name === 'documents') return join(homedir(), 'Documents')
if (name === 'desktop') return join(homedir(), 'Desktop')
if (name === 'downloads') return join(homedir(), 'Downloads')
if (name === 'temp') return tmpdir()
if (name === 'appData') return appDataPath()
return process.cwd()
}
export const app = {
isPackaged: Boolean(process.resourcesPath && process.env.NODE_ENV !== 'development'),
getPath,
getAppPath: () => process.cwd(),
getName: () => 'WeFlow',
getVersion: () => process.env.npm_package_version || '0.0.0'
}
export const BrowserWindow = { getAllWindows: () => [] }
export const dialog = { showMessageBox: async () => ({ response: 0, checkboxChecked: false }) }
export const shell = { openExternal: async () => false, showItemInFolder: () => {} }
export const ipcMain = { on: () => {}, handle: () => {}, removeHandler: () => {} }
export const ipcRenderer = { sendSync: () => ({}) }
export const safeStorage = {
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(String(value || ''), 'utf8'),
decryptString: (value) => Buffer.isBuffer(value) ? value.toString('utf8') : Buffer.from(value).toString('utf8')
}
export const Notification = class {
static isSupported() { return false }
on() { return this }
show() {}
close() {}
}
export default { app, BrowserWindow, dialog, shell, ipcMain, ipcRenderer, safeStorage, Notification }
`
},
transform(code: string, id: string) {
if (!/\.[cm]?[jt]s$/.test(id)) return null
if (!code.includes("'electron'") && !code.includes('"electron"')) return null
const next = code
.replace(/from\s+(['"])electron\1/g, `from '${virtualId}'`)
.replace(/import\s*\(\s*(['"])electron\1\s*\)/g, `import('${virtualId}')`)
.replace(/require\s*\(\s*(['"])electron\1\s*\)/g, `require('${virtualId}')`)
return next === code ? null : { code: next, map: null }
}
}
}
export default defineConfig({
base: './',
server: {
@@ -142,6 +213,7 @@ export default defineConfig({
entry: 'electron/exportWorker.ts',
onstart: handleElectronOnStart,
vite: {
plugins: [exportWorkerElectronShimPlugin()],
build: {
outDir: 'dist-electron',
rollupOptions: {