Compare commits

..

35 Commits

Author SHA1 Message Date
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
cc
27690ee7fa Merge pull request #847 from hicccc77/dev
Dev
2026-04-26 11:13:55 +08:00
cc
81ade84a77 fix actions fixed prerelease sync 2026-04-26 11:13:04 +08:00
姜北尘
bb42a7c0b2 fix(export): 修复朋友圈导出控制按钮 2026-04-25 23:54:32 +08:00
cc
87d894b1f9 Merge pull request #845 from BeiChen-CN/codex/export-pause-cancel
feat(export): 添加导出暂停取消控制
2026-04-25 23:32:53 +08:00
姜北尘
1b75986987 feat(export): 添加导出暂停取消控制 2026-04-25 23:24:27 +08:00
cc
32aab8d490 fix: 迁移图片资源保留原始dat 2026-04-25 22:55:28 +08:00
cc
8e2a6ec933 优化防撤回会话列表 2026-04-25 19:22:35 +08:00
cc
fc3356ece2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-25 18:38:15 +08:00
cc
cd1ecf0ef6 修复文案描述与添加hardlink备份支持 2026-04-25 18:38:12 +08:00
cc
9e6bf0f21a Merge pull request #842 from Jasonzhu1207/main
feat: add silent startup
2026-04-25 18:37:51 +08:00
Jason
9ea34d74c2 Merge pull request #34 from Jasonzhu1207/feat/silent-startup-tray
feat: add silent startup to tray background
2026-04-25 17:49:10 +08:00
Jason
42d4982728 feat(settings): add silent startup to tray 2026-04-25 17:42:13 +08:00
cc
f07e23b144 完善数据迁移 2026-04-25 17:41:14 +08:00
cc
6cf67828a2 修复Linux密钥问题 2026-04-25 15:50:45 +08:00
46 changed files with 3133 additions and 643 deletions

View File

@@ -58,12 +58,26 @@ wait_for_release_id() {
local i
local release_id
local release_api_url
for ((i = 1; i <= attempts; i++)); do
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}"
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
@@ -71,6 +85,7 @@ wait_for_release_id() {
done
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
gh release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
@@ -87,9 +102,10 @@ settle_release_state() {
local draft_state
local prerelease_state
for ((i = 1; i <= attempts; i++)); do
gh release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 2>/dev/null || echo false)"
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
return 0
fi
@@ -100,10 +116,19 @@ settle_release_state() {
done
echo "Failed to settle release state for tag '$tag'." >&2
gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
print_release_state() {
local repo="$1"
local tag="$2"
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
}
wait_for_release_absent() {
local repo="$1"
local tag="$2"

View File

@@ -386,4 +386,4 @@ jobs:
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
print_release_state "$REPO" "$TAG"

View File

@@ -429,4 +429,4 @@ jobs:
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
print_release_state "$REPO" "$TAG"

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. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
### 常见报错与应对方法

View File

@@ -1,19 +1,132 @@
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
}
const config = workerData as ExportWorkerConfig
const controlState = {
pauseRequested: false,
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') {
controlState.pauseRequested = true
return
}
if (message.type === 'export:resume') {
controlState.pauseRequested = false
return
}
if (message.type === 'export:cancel') {
controlState.stopRequested = true
controlState.pauseRequested = false
}
})
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
@@ -35,20 +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(
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' },
(progress) => {
parentPort?.postMessage({
type: 'export:progress',
data: progress
})
}
onProgress,
taskControl
)
}
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
@@ -57,6 +199,8 @@ async function run() {
}
run().catch((error) => {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:error',
error: String(error)

View File

@@ -16,13 +16,13 @@ import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { exportTaskControlService } from './services/exportTaskControlService'
import { KeyService } from './services/keyService'
import { KeyServiceLinux } from './services/keyServiceLinux'
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'
@@ -64,6 +64,42 @@ const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
return 'stable'
})()
let configService: ConfigService | null = null
const activeExportWorkers = new Map<string, Worker>()
const activeExportTasks = new Set<string>()
const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => {
const worker = activeExportWorkers.get(taskId)
if (!worker) return
try {
worker.postMessage({ type: `export:${action}` })
} catch (error) {
console.warn(`[export-task-control] failed to post ${action} to worker:`, error)
}
}
const finalizeExportTaskControlResult = async (taskId: string, result: any) => {
if (!taskId) return result
if (result?.stopped) {
const cleanup = await exportTaskControlService.cleanupTask(taskId)
if (!cleanup.success) {
return {
...result,
success: false,
error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}`
}
}
return {
...result,
cleanup
}
}
if (!result?.paused) {
exportTaskControlService.releaseTask(taskId)
}
return result
}
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
@@ -748,6 +784,10 @@ const getWindowCloseBehavior = (): WindowCloseBehavior => {
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
}
const isSilentStartupEnabled = (): boolean => {
return configService?.get('silentStartup') === true
}
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
if (isClosePromptVisible) return
isClosePromptVisible = true
@@ -2237,6 +2277,10 @@ function registerIpcHandlers() {
return chatService.getNewMessages(sessionId, minTime, limit)
})
ipcMain.handle('chat:getAntiRevokeSessions', async () => {
return chatService.getAntiRevokeSessions()
})
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
return chatService.updateMessage(sessionId, localId, createTime, newContent)
})
@@ -2628,16 +2672,25 @@ function registerIpcHandlers() {
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
const exportOptions = { ...(options || {}) }
const taskId = normalizeExportTaskId(exportOptions.taskId)
delete exportOptions.taskId
const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined
if (taskId) activeExportTasks.add(taskId)
return snsService.exportTimeline(
try {
const result = await snsService.exportTimeline(
exportOptions,
(progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress)
}
}
},
taskControl
)
return finalizeExportTaskControlResult(taskId, result)
} finally {
if (taskId) activeExportTasks.delete(taskId)
}
})
ipcMain.handle('sns:selectExportDir', async () => {
@@ -2960,7 +3013,40 @@ function registerIpcHandlers() {
return exportService.getExportStats(sessionIds, options)
})
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
const normalizedTaskId = normalizeExportTaskId(taskId)
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
const success = exportTaskControlService.pauseTask(normalizedTaskId)
if (success) postExportWorkerControl(normalizedTaskId, 'pause')
return { success }
})
ipcMain.handle('export:resumeTask', async (_, taskId: string) => {
const normalizedTaskId = normalizeExportTaskId(taskId)
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
const success = exportTaskControlService.resumeTask(normalizedTaskId)
if (success) postExportWorkerControl(normalizedTaskId, 'resume')
return { success }
})
ipcMain.handle('export:cancelTask', async (_, taskId: string) => {
const normalizedTaskId = normalizeExportTaskId(taskId)
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
const success = exportTaskControlService.cancelTask(normalizedTaskId)
if (success) postExportWorkerControl(normalizedTaskId, 'cancel')
if (success && !activeExportTasks.has(normalizedTaskId)) {
const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId)
return cleanup.success
? { success: true, cleanup }
: { success: false, error: cleanup.error || '清理已导出文件失败' }
}
return { success }
})
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
const taskId = normalizeExportTaskId(controlOptions?.taskId)
if (taskId) exportTaskControlService.createControl(taskId, outputDir)
if (taskId) activeExportTasks.add(taskId)
const PROGRESS_FORWARD_INTERVAL_MS = 180
let pendingProgress: ExportProgress | null = null
let progressTimer: NodeJS.Timeout | null = null
@@ -3004,17 +3090,13 @@ function registerIpcHandlers() {
queueProgress(progress)
}
const runMainFallback = async (reason: string) => {
console.warn(`[fallback-export-main] ${reason}`)
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}
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')
@@ -3028,9 +3110,12 @@ function registerIpcHandlers() {
sessionIds,
outputDir,
options,
taskId,
dbPath,
decryptKey,
myWxid,
imageXorKey: imageKeys.xorKey,
imageAesKey: imageKeys.aesKey,
resourcesPath,
userDataPath,
logEnabled
@@ -3038,9 +3123,15 @@ function registerIpcHandlers() {
})
let settled = false
if (taskId) {
activeExportWorkers.set(taskId, worker)
}
const finalizeResolve = (value: any) => {
if (settled) return
settled = true
if (taskId && activeExportWorkers.get(taskId) === worker) {
activeExportWorkers.delete(taskId)
}
worker.removeAllListeners()
void worker.terminate()
resolve(value)
@@ -3048,6 +3139,9 @@ function registerIpcHandlers() {
const finalizeReject = (error: Error) => {
if (settled) return
settled = true
if (taskId && activeExportWorkers.get(taskId) === worker) {
activeExportWorkers.delete(taskId)
}
worker.removeAllListeners()
void worker.terminate()
reject(error)
@@ -3058,6 +3152,28 @@ 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
}
if (msg && msg.type === 'export:createdDir' && taskId) {
exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || ''))
return
}
if (msg && msg.type === 'export:result') {
finalizeResolve(msg.data)
return
@@ -3083,10 +3199,27 @@ function registerIpcHandlers() {
}
try {
return await runWorker()
const result = await runWorker()
return await finalizeExportTaskControlResult(taskId, result)
} catch (error) {
return 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)
flushProgress()
if (progressTimer) {
clearTimeout(progressTimer)
@@ -3095,12 +3228,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}` }
}
})
// 数据分析相关
@@ -3740,7 +3997,16 @@ function checkForUpdatesOnStartup() {
}
app.whenReady().then(async () => {
// 立即创建 Splash 窗口,确保用户尽快看到反馈
// 先初始化配置,以便在启动早期判定是否需要静默启动
configService = new ConfigService()
applyAutoUpdateChannel('startup')
syncLaunchAtStartupPreference()
const onboardingDone = configService.get('onboardingDone') === true
const startInBackground = onboardingDone && isSilentStartupEnabled()
shouldShowMain = onboardingDone
if (!startInBackground) {
// 非静默模式下显示 Splash提供启动反馈
createSplashWindow()
// 等待 Splash 页面加载完成后再推送进度
@@ -3756,6 +4022,7 @@ app.whenReady().then(async () => {
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
.catch(() => {})
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
@@ -3783,13 +4050,7 @@ app.whenReady().then(async () => {
})
}
// 初始化配置服务
updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService()
applyAutoUpdateChannel('startup')
syncLaunchAtStartupPreference()
const onboardingDone = configService.get('onboardingDone') === true
shouldShowMain = onboardingDone
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {
@@ -3956,6 +4217,8 @@ app.whenReady().then(async () => {
if (!onboardingDone) {
createOnboardingWindow()
} else if (startInBackground && tray) {
mainWindow?.hide()
} else {
mainWindow?.show()
}

View File

@@ -185,6 +185,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
@@ -462,8 +463,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
pauseTask: (taskId: string) =>
ipcRenderer.invoke('export:pauseTask', taskId),
resumeTask: (taskId: string) =>
ipcRenderer.invoke('export:resumeTask', taskId),
cancelTask: (taskId: string) =>
ipcRenderer.invoke('export:cancelTask', taskId),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>

View File

@@ -1,5 +1,5 @@
import { BrowserWindow, app } from 'electron'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
import { basename, dirname, join, relative, resolve, sep } from 'path'
import { tmpdir } from 'os'
@@ -7,11 +7,12 @@ import * as tar from 'tar'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { expandHomePath } from '../utils/pathUtils'
import { decryptDatViaNative, encryptDatViaNative } from './nativeImageDecrypt'
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
type BackupResourceKind = 'image' | 'video' | 'file'
const TEMP_MARKER = '.weflow-backup-temp'
const TEMP_TTL_MS = 24 * 60 * 60 * 1000
export interface BackupOptions {
includeImages?: boolean
@@ -140,8 +141,42 @@ function hasResourceOptions(options: BackupOptions): boolean {
return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
}
function normalizeArchivePath(value: string): string {
return String(value || '').replace(/\\/g, '/')
}
export class BackupService {
private configService = new ConfigService()
private cleanedTempDirs = false
private cleanupStaleTempDirs(): void {
if (this.cleanedTempDirs) return
this.cleanedTempDirs = true
const root = tmpdir()
const now = Date.now()
try {
for (const entry of readdirSync(root)) {
if (!entry.startsWith('weflow-backup-')) continue
const dir = join(root, entry)
const marker = join(dir, TEMP_MARKER)
try {
const stat = statSync(dir)
if (!stat.isDirectory()) continue
if (!existsSync(marker)) continue
const age = now - stat.mtimeMs
if (age < TEMP_TTL_MS) continue
rmSync(dir, { recursive: true, force: true })
} catch {}
}
} catch {}
}
private async createTempDir(prefix: string): Promise<string> {
this.cleanupStaleTempDirs()
const dir = await mkdtemp(join(tmpdir(), prefix))
await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8')
return dir
}
private buildWxidCandidates(wxid: string): string[] {
const wxidCandidates = Array.from(new Set([
@@ -253,27 +288,6 @@ export class BackupService {
return suffixMatch ? suffixMatch[1] : trimmed
}
private parseImageXorKey(value: unknown): number {
if (typeof value === 'number') return value
const text = String(value ?? '').trim()
if (!text) return Number.NaN
return text.toLowerCase().startsWith('0x') ? parseInt(text, 16) : parseInt(text, 10)
}
private getImageKeysForWxid(wxid: string): { xorKey: number; aesKey?: string } | null {
const wxidConfigs = this.configService.get('wxidConfigs') || {}
const candidates = this.buildWxidCandidates(wxid)
const matchedKey = Object.keys(wxidConfigs).find((key) => {
const cleanKey = this.cleanAccountDirName(key).toLowerCase()
return candidates.some(candidate => cleanKey === candidate.toLowerCase())
})
const cfg = matchedKey ? wxidConfigs[matchedKey] : undefined
const xorKey = this.parseImageXorKey(cfg?.imageXorKey ?? this.configService.get('imageXorKey'))
if (!Number.isFinite(xorKey)) return null
const aesKey = String(cfg?.imageAesKey ?? this.configService.get('imageAesKey') ?? '').trim()
return { xorKey, aesKey: aesKey || undefined }
}
private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise<string[]> {
const dir = join(root, rel)
const files: string[] = []
@@ -295,7 +309,7 @@ export class BackupService {
}
private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
const normalized = String(archivePath || '').replace(/\\/g, '/')
const normalized = normalizeArchivePath(archivePath)
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
const root = resolve(extractDir)
const target = resolve(join(extractDir, normalized))
@@ -303,8 +317,12 @@ export class BackupService {
return target
}
private resolveStagingPath(stagingDir: string, archivePath: string): string | null {
return this.resolveExtractedPath(stagingDir, archivePath)
}
private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
const normalized = String(relativePath || '').replace(/\\/g, '/')
const normalized = normalizeArchivePath(relativePath)
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
const root = resolve(accountDir)
const target = resolve(join(accountDir, normalized))
@@ -351,6 +369,18 @@ export class BackupService {
}
}
private async writeTarEntryToFile(entry: any, outputPath: string): Promise<void> {
mkdirSync(dirname(outputPath), { recursive: true })
await new Promise<void>((resolvePromise, rejectPromise) => {
const out = createWriteStream(outputPath)
const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
out.on('finish', resolvePromise)
out.on('error', fail)
entry.on('error', fail)
entry.pipe(out)
})
}
private async listChatImageDatFiles(accountDir: string): Promise<string[]> {
const attachRoot = join(accountDir, 'msg', 'attach')
const result: string[] = []
@@ -444,7 +474,7 @@ export class BackupService {
}
private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns') return kind
if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
}
@@ -468,6 +498,7 @@ export class BackupService {
if (kind === 'contact') return 'contact/contact.db'
if (kind === 'emoticon') return 'emoticon/emoticon.db'
if (kind === 'sns') return 'sns/sns.db'
if (kind === 'hardlink') return 'hardlink/hardlink.db'
return null
}
@@ -517,12 +548,19 @@ export class BackupService {
join(dirname(dbStorage), 'sns', 'sns.db')
])
}
if (kind === 'hardlink') {
return this.findFirstExisting([
join(dbStorage, 'hardlink', 'hardlink.db'),
join(dbStorage, 'hardlink.db'),
join(dirname(dbStorage), 'hardlink.db')
])
}
return ''
}
private async collectDatabases(dbStorage: string): Promise<Array<Omit<BackupDbEntry, 'tables'>>> {
const result: Array<Omit<BackupDbEntry, 'tables'>> = []
for (const kind of ['session', 'contact', 'emoticon', 'sns'] as const) {
for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) {
const dbPath = this.resolveKnownDbPath(kind, dbStorage)
result.push({
id: kind,
@@ -565,11 +603,9 @@ export class BackupService {
manifest: BackupManifest
): Promise<void> {
const accountDir = dirname(connected.dbStorage)
const keys = this.getImageKeysForWxid(connected.wxid)
const imagesDir = join(stagingDir, 'resources', 'images')
const imagePaths = await this.listChatImageDatFiles(accountDir)
if (imagePaths.length === 0) return
if (!keys) throw new Error('存在图片资源,但未配置图片解密密钥')
mkdirSync(imagesDir, { recursive: true })
const resources: BackupResourceEntry[] = []
@@ -580,18 +616,16 @@ export class BackupService {
if (!relativeTarget) continue
emitImageProgress({
phase: 'exporting',
message: '正在解密图片资源',
message: '正在打包图片资源',
current: index + 1,
total: imagePaths.length,
detail: relativeTarget
})
const decrypted = decryptDatViaNative(sourcePath, keys.xorKey, keys.aesKey)
if (!decrypted) continue
const archivePath = toArchivePath(join('resources', 'images', `${relativeTarget}${decrypted.ext || '.bin'}`))
const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
const outputPath = join(stagingDir, archivePath)
mkdirSync(dirname(outputPath), { recursive: true })
await writeFile(outputPath, decrypted.data)
await this.stagePlainResource(sourcePath, outputPath)
const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
const stat = statSync(sourcePath)
resources.push({
kind: 'image',
id: relativeTarget,
@@ -599,8 +633,7 @@ export class BackupService {
sourceFileName: basename(sourcePath),
archivePath,
targetRelativePath: relativeTarget,
ext: decrypted.ext || undefined,
size: decrypted.data.length
size: stat.size
})
if (index % 20 === 0) await delay()
}
@@ -676,7 +709,7 @@ export class BackupService {
return { success: false, error: connected.error || '数据库未连接' }
}
stagingDir = await mkdtemp(join(tmpdir(), 'weflow-backup-'))
stagingDir = await this.createTempDir('weflow-backup-')
const snapshotsDir = join(stagingDir, 'snapshots')
mkdirSync(snapshotsDir, { recursive: true })
@@ -814,7 +847,7 @@ export class BackupService {
let extractDir = ''
try {
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-inspect-'))
extractDir = await this.createTempDir('weflow-backup-inspect-')
await tar.x({
file: archivePath,
cwd: extractDir,
@@ -836,12 +869,119 @@ export class BackupService {
}
}
private async streamRestoreArchive(
archivePath: string,
extractDir: string,
manifest: BackupManifest,
connected: { dbStorage: string; wxid?: string },
startCurrent: number,
total: number
): Promise<{ current: number; skipped: number }> {
const snapshotPaths = new Set<string>()
for (const db of manifest.databases || []) {
for (const table of db.tables || []) {
const path = normalizeArchivePath(table.snapshotPath)
if (path) snapshotPaths.add(path)
}
}
const imageByPath = new Map<string, BackupResourceEntry>()
for (const image of manifest.resources?.images || []) {
const path = normalizeArchivePath(image.archivePath)
if (path) imageByPath.set(path, image)
}
const plainByPath = new Map<string, BackupResourceEntry>()
for (const resource of [
...(manifest.resources?.videos || []),
...(manifest.resources?.files || [])
]) {
const path = normalizeArchivePath(resource.archivePath)
if (path) plainByPath.set(path, resource)
}
const accountDir = dirname(connected.dbStorage)
let current = startCurrent
let skipped = 0
const pending: Promise<void>[] = []
const emitRestoreProgress = createThrottledProgressEmitter(160)
await tar.t({
file: archivePath,
onReadEntry: (entry: any) => {
const entryPath = normalizeArchivePath(entry.path)
if (snapshotPaths.has(entryPath)) {
const outputPath = this.resolveStagingPath(extractDir, entryPath)
if (!outputPath) {
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, outputPath))
return
}
const image = imageByPath.get(entryPath)
if (image) {
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
if (!targetPath) {
skipped += 1
entry.resume()
return
}
current += 1
emitRestoreProgress({
phase: 'restoring',
message: '正在写回图片资源',
current,
total,
detail: image.md5 || image.targetRelativePath
})
if (existsSync(targetPath)) {
skipped += 1
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, targetPath))
return
}
const resource = plainByPath.get(entryPath)
if (resource) {
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
current += 1
emitRestoreProgress({
phase: 'restoring',
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
current,
total,
detail: resource.targetRelativePath
})
if (!targetPath || existsSync(targetPath)) {
skipped += 1
entry.resume()
return
}
pending.push(this.writeTarEntryToFile(entry, targetPath))
return
}
entry.resume()
}
} as any)
await Promise.all(pending)
return { current, skipped }
}
async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
let extractDir = ''
try {
emitBackupProgress({ phase: 'inspecting', message: '正在解包备份' })
extractDir = await mkdtemp(join(tmpdir(), 'weflow-backup-restore-'))
await tar.x({ file: archivePath, cwd: extractDir })
emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
extractDir = await this.createTempDir('weflow-backup-restore-')
await tar.x({
file: archivePath,
cwd: extractDir,
filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json'
} as any)
const manifestPath = join(extractDir, 'manifest.json')
if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
@@ -866,6 +1006,26 @@ export class BackupService {
let ignored = 0
let skipped = 0
let current = 0
if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
emitBackupProgress({
phase: 'inspecting',
message: '正在按需读取备份包',
current: 0,
total: totalRestoreJobs,
detail: archivePath
})
const streamed = await this.streamRestoreArchive(
archivePath,
extractDir,
manifest,
{ dbStorage: connected.dbStorage, wxid: connected.wxid },
0,
totalRestoreJobs
)
current = streamed.current
skipped += streamed.skipped
}
for (const job of tableJobs) {
current++
const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
@@ -907,68 +1067,6 @@ export class BackupService {
if (current % 4 === 0) await delay()
}
if (imageJobs.length > 0) {
const targetWxid = connected.wxid || String(manifest.source?.wxid || '').trim()
const imageKeys = this.getImageKeysForWxid(targetWxid)
if (!imageKeys) throw new Error('备份包包含图片资源,但目标账号未配置图片加密密钥')
const accountDir = dirname(connected.dbStorage)
for (const image of imageJobs) {
current += 1
emitBackupProgress({
phase: 'restoring',
message: '正在加密并写回图片资源',
current,
total: totalRestoreJobs,
detail: image.md5 || image.targetRelativePath
})
const inputPath = this.resolveExtractedPath(extractDir, image.archivePath)
const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
if (!inputPath || !targetPath || !existsSync(inputPath)) {
skipped += 1
continue
}
if (existsSync(targetPath)) {
skipped += 1
continue
}
const encrypted = encryptDatViaNative(inputPath, imageKeys.xorKey, imageKeys.aesKey)
if (!encrypted) {
skipped += 1
continue
}
mkdirSync(dirname(targetPath), { recursive: true })
await writeFile(targetPath, encrypted)
if (current % 16 === 0) await delay()
}
}
if (plainResourceJobs.length > 0) {
const accountDir = dirname(connected.dbStorage)
for (const resource of plainResourceJobs) {
current += 1
emitBackupProgress({
phase: 'restoring',
message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
current,
total: totalRestoreJobs,
detail: resource.targetRelativePath
})
const inputPath = this.resolveExtractedPath(extractDir, resource.archivePath)
const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
if (!inputPath || !targetPath || !existsSync(inputPath)) {
skipped += 1
continue
}
if (existsSync(targetPath)) {
skipped += 1
continue
}
mkdirSync(dirname(targetPath), { recursive: true })
await copyFile(inputPath, targetPath)
if (current % 30 === 0) await delay()
}
}
emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
return { success: true, inserted, ignored, skipped }
} catch (e) {

View File

@@ -6,7 +6,6 @@ import * as https from 'https'
import * as http from 'http'
import * as fzstd from 'fzstd'
import * as crypto from 'crypto'
import { app, BrowserWindow, dialog } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
@@ -18,6 +17,7 @@ import { voiceTranscribeService } from './voiceTranscribeService'
import { ImageDecryptService } from './imageDecryptService'
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
import { LRUCache } from '../utils/LRUCache.js'
import { getAppPathFallback, getElectronBrowserWindow, getElectronDialog, getPathFallback, isElectronAppPackaged } from './electronRuntime'
export interface ChatSession {
username: string
@@ -498,7 +498,7 @@ class ChatService {
}
private async maybeShowInitFailureDialog(errorMessage: string): Promise<void> {
if (!app.isPackaged) return
if (!isElectronAppPackaged()) return
if (this.initFailureDialogShown) return
const code = this.extractErrorCode(errorMessage)
@@ -519,6 +519,8 @@ class ChatService {
].join('\n')
try {
const dialog = getElectronDialog()
if (!dialog?.showMessageBox) return
await dialog.showMessageBox({
type: 'error',
title: 'WeFlow 启动失败',
@@ -600,7 +602,7 @@ class ChatService {
console.error('[ChatService] 数据库监听回调失败:', error)
}
}
const windows = BrowserWindow.getAllWindows()
const windows = getElectronBrowserWindow()?.getAllWindows?.() || []
// 广播给所有渲染进程窗口
windows.forEach((win) => {
if (!win.isDestroyed()) {
@@ -666,6 +668,9 @@ class ChatService {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
if (!wcdbService.isReady()) {
this.monitorSetup = false
}
const result = await this.connect()
if (!result.success) {
this.connected = false
@@ -709,6 +714,7 @@ class ChatService {
console.error('ChatService: 关闭数据库失败:', e)
}
this.connected = false
this.monitorSetup = false
}
/**
@@ -745,8 +751,12 @@ class ChatService {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
const result = validIds.length > 0
? await wcdbService.checkMessageAntiRevokeTriggers(validIds)
: { success: true, rows: [] }
if (!result.success) return result
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
} catch (e) {
return { success: false, error: String(e) }
}
@@ -760,8 +770,12 @@ class ChatService {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
const result = validIds.length > 0
? await wcdbService.installMessageAntiRevokeTriggers(validIds)
: { success: true, rows: [] }
if (!result.success) return result
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
} catch (e) {
return { success: false, error: String(e) }
}
@@ -775,8 +789,12 @@ class ChatService {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) return { success: false, error: connectResult.error }
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
const result = validIds.length > 0
? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds)
: { success: true, rows: [] }
if (!result.success) return result
return { success: true, rows: [...(result.rows || []), ...invalidRows] }
} catch (e) {
return { success: false, error: String(e) }
}
@@ -934,6 +952,191 @@ class ChatService {
}
}
async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
try {
const result = await this.getSessions()
if (!result.success || !Array.isArray(result.sessions)) {
return { success: false, error: result.error || '获取会话失败' }
}
return {
success: true,
sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_'))
}
} catch (e) {
console.error('ChatService: 获取防撤回会话列表失败:', e)
return { success: false, error: String(e) }
}
}
private getSessionUsername(row: Record<string, any>): string {
return String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
row.talker_id ||
row.talkerId ||
''
).trim()
}
private isAntiRevokeContactRow(username: string, row: Record<string, any>): boolean {
if (!username) return false
if (username.endsWith('@chatroom')) return true
if (username.startsWith('gh_')) return false
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
const lowered = username.toLowerCase()
if (this.isEnterpriseOpenimUsername(username)) {
return this.isAllowedEnterpriseOpenimByLocalType(username, localType)
}
if (lowered.startsWith('weixin') && lowered !== 'weixin') return true
return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)
}
private async loadAntiRevokeContactMap(usernames: string[]): Promise<Map<string, { displayName?: string }>> {
const targets = Array.from(new Set((usernames || []).map((value) => String(value || '').trim()).filter(Boolean)))
const map = new Map<string, { displayName?: string }>()
if (targets.length === 0) return map
try {
const contactResult = await wcdbService.getContactsCompact(targets)
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return map
for (const row of contactResult.contacts as Record<string, any>[]) {
const username = String(row.username || '').trim()
if (!username || !this.isAntiRevokeContactRow(username, row)) continue
map.set(username, {
displayName: String(row.remark || row.nick_name || row.nickName || row.alias || username).trim()
})
}
} catch {
return map
}
return map
}
private async hasAntiRevokeMessageTables(sessionId: string): Promise<boolean> {
try {
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) return false
return tableStatsResult.tables.some((row: Record<string, any>) => {
const tableName = String(row.table_name || row.tableName || '').trim()
return tableName.length > 0
})
} catch {
return false
}
}
private async buildAntiRevokeSessionsFromRows(rows: Record<string, any>[]): Promise<ChatSession[]> {
if (rows.length > 0 && (rows[0]._error || rows[0]._info)) return []
const candidateRows: Array<{ username: string; row: Record<string, any> }> = []
const privateCandidateIds: string[] = []
const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) => this.getSessionUsername(row)))
for (const row of rows) {
const username = this.getSessionUsername(row)
if (!username) continue
let sessionLocalType = this.getSessionLocalType(row)
if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) {
sessionLocalType = openimLocalTypeMap.get(username)
}
if (!this.shouldKeepSession(username, sessionLocalType)) continue
if (username.endsWith('@chatroom')) {
candidateRows.push({ username, row })
} else {
privateCandidateIds.push(username)
candidateRows.push({ username, row })
}
}
const contactMap = await this.loadAntiRevokeContactMap(privateCandidateIds)
const sessions: ChatSession[] = []
const myWxid = this.configService.get('myWxid')
const now = Date.now()
for (const { username, row } of candidateRows) {
const isGroup = username.endsWith('@chatroom')
if (!isGroup && !contactMap.has(username)) continue
if (!await this.hasAntiRevokeMessageTables(username)) continue
const sortTs = parseInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.sort_time ||
row.sortTime ||
'0',
10
)
const lastTs = parseInt(
row.last_timestamp ||
row.lastTimestamp ||
row.last_msg_time ||
row.lastMsgTime ||
String(sortTs),
10
)
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
const cached = this.avatarCache.get(username)
const contact = contactMap.get(username)
const session: ChatSession = {
username,
type: parseInt(row.type || '0', 10),
unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10),
summary: summary || this.getMessageTypeLabel(lastMsgType),
sortTimestamp: sortTs,
lastTimestamp: lastTs,
lastMsgType,
displayName: contact?.displayName || cached?.displayName || username,
avatarUrl: cached?.avatarUrl,
lastMsgSender: row.last_msg_sender,
lastSenderDisplayName: row.last_sender_display_name,
selfWxid: myWxid
}
const cachedStatus = this.sessionStatusCache.get(username)
if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) {
session.isFolded = cachedStatus.isFolded
session.isMuted = cachedStatus.isMuted
}
sessions.push(session)
}
return sessions
}
private async filterAntiRevokeSessionIds(sessionIds: string[]): Promise<{
validIds: string[]
invalidRows: Array<{ sessionId: string; success: false; error: string }>
}> {
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
if (normalizedIds.length === 0) return { validIds: [], invalidRows: [] }
const sessionsResult = await this.getAntiRevokeSessions()
const allowedIds = new Set((sessionsResult.sessions || []).map((session) => session.username))
const validIds = normalizedIds.filter((sessionId) => allowedIds.has(sessionId))
const invalidRows = normalizedIds
.filter((sessionId) => !allowedIds.has(sessionId))
.map((sessionId) => ({
sessionId,
success: false as const,
error: '该会话不是联系人或群聊,或不存在可安装防撤回的消息表'
}))
return { validIds, invalidRows }
}
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
try {
@@ -6979,7 +7182,7 @@ class ChatService {
return join(cachePath, 'Voices')
}
// 回退到默认目录
const documentsPath = app.getPath('documents')
const documentsPath = getPathFallback('documents')
return join(documentsPath, 'WeFlow', 'Voices')
}
@@ -6989,7 +7192,7 @@ class ChatService {
return join(cachePath, 'Emojis')
}
// 回退到默认目录
const documentsPath = app.getPath('documents')
const documentsPath = getPathFallback('documents')
return join(documentsPath, 'WeFlow', 'Emojis')
}
@@ -8234,13 +8437,13 @@ class ChatService {
private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise<Buffer | null> {
try {
let wasmPath: string
if (app.isPackaged) {
if (isElectronAppPackaged()) {
wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
if (!existsSync(wasmPath)) {
wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
} else {
wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
wasmPath = join(getAppPathFallback(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm')
}
if (!existsSync(wasmPath)) {
@@ -8428,7 +8631,7 @@ class ChatService {
/** 获取持久化转写缓存文件路径 */
private getTranscriptCachePath(): string {
const cachePath = this.configService.get('cachePath')
const base = cachePath || join(app.getPath('documents'), 'WeFlow')
const base = cachePath || join(getPathFallback('documents'), 'WeFlow')
return join(base, 'Voices', 'transcripts.json')
}

View File

@@ -1,13 +1,14 @@
import { join } from 'path'
import { app, safeStorage } from 'electron'
import { dirname, join } from 'path'
import crypto from 'crypto'
import Store from 'electron-store'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { expandHomePath } from '../utils/pathUtils'
import { getElectronSafeStorage, getPathFallback, isWorkerRuntime } from './electronRuntime'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const isSafeStorageAvailable = (): boolean => {
try {
const safeStorage = getElectronSafeStorage()
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
} catch {
return false
@@ -36,6 +37,7 @@ interface ConfigSchema {
language: string
logEnabled: boolean
launchAtStartup?: boolean
silentStartup?: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
@@ -84,7 +86,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
@@ -111,6 +119,68 @@ interface ConfigSchema {
aiInsightDebugLogEnabled: boolean
}
interface ConfigStoreLike<T extends Record<string, any>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
clear(): void
store: T
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
class JsonConfigStore<T extends Record<string, any>> implements ConfigStoreLike<T> {
private readonly filePath: string
private readonly defaults: T
private data: T
constructor(options: { name: string; defaults: T; cwd?: string }) {
const baseDir = options.cwd || getPathFallback('userData')
mkdirSync(baseDir, { recursive: true })
this.filePath = join(baseDir, `${options.name}.json`)
this.defaults = cloneJson(options.defaults)
this.data = cloneJson(options.defaults)
this.load()
}
get store(): T {
return this.data
}
private load(): void {
try {
if (!existsSync(this.filePath)) return
const raw = readFileSync(this.filePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.data = { ...cloneJson(this.defaults), ...parsed }
}
} catch {
this.data = cloneJson(this.defaults)
}
}
private persist(): void {
mkdirSync(dirname(this.filePath), { recursive: true })
writeFileSync(this.filePath, JSON.stringify(this.data), 'utf8')
}
get<K extends keyof T>(key: K): T[K] {
return this.data[key]
}
set<K extends keyof T>(key: K, value: T[K]): void {
this.data[key] = value
this.persist()
}
clear(): void {
this.data = cloneJson(this.defaults)
this.persist()
}
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'decryptKey',
@@ -130,7 +200,7 @@ const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService {
private static instance: ConfigService
private store!: Store<ConfigSchema>
private store!: ConfigStoreLike<ConfigSchema>
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
@@ -163,6 +233,7 @@ export class ConfigService {
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
silentStartup: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
@@ -203,6 +274,9 @@ export class ConfigService {
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
@@ -223,37 +297,18 @@ export class ConfigService {
aiInsightDebugLogEnabled: false
}
const storeOptions: any = {
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
this.store = new JsonConfigStore<ConfigSchema>({
name: 'WeFlow-config',
defaults,
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
}
const runningInWorker = process.env.WEFLOW_WORKER === '1'
if (runningInWorker) {
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
if (cwd) {
storeOptions.cwd = cwd
}
}
cwd: cwd || undefined
})
try {
this.store = new Store<ConfigSchema>(storeOptions)
} catch (error) {
const message = String((error as Error)?.message || error || '')
if (message.includes('projectName')) {
const fallbackOptions = {
...storeOptions,
projectName: 'WeFlow',
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
}
this.store = new Store<ConfigSchema>(fallbackOptions)
} else {
throw error
}
}
if (!isWorkerRuntime()) {
this.migrateAuthFields()
this.migrateAiConfig()
}
}
// === 状态查询 ===
@@ -354,6 +409,8 @@ export class ConfigService {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!isSafeStorageAvailable()) return plaintext
const safeStorage = getElectronSafeStorage()
if (!safeStorage) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
@@ -362,6 +419,8 @@ export class ConfigService {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!isSafeStorageAvailable()) return ''
const safeStorage = getElectronSafeStorage()
if (!safeStorage) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
@@ -829,7 +888,7 @@ export class ConfigService {
if (workerUserDataPath) {
return workerUserDataPath
}
return app?.getPath?.('userData') || process.cwd()
return getPathFallback('userData')
}
getCacheBasePath(): string {

View File

@@ -1,6 +1,5 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface ContactCacheEntry {

View File

@@ -0,0 +1,96 @@
import { homedir, tmpdir } from 'os'
import { join } from 'path'
type RuntimeRequire = (id: string) => any
let cachedElectron: any | null | false = null
export function isWorkerRuntime(): boolean {
return process.env.WEFLOW_WORKER === '1'
}
export function getElectronModule(): any | null {
if (isWorkerRuntime()) return null
if (cachedElectron !== null) return cachedElectron || null
try {
const runtimeRequire = (0, eval)('require') as RuntimeRequire
cachedElectron = runtimeRequire('electron')
} catch {
cachedElectron = false
}
return cachedElectron || null
}
export function getElectronApp(): any | null {
return getElectronModule()?.app || null
}
export function getElectronBrowserWindow(): any | null {
return getElectronModule()?.BrowserWindow || null
}
export function getElectronDialog(): any | null {
return getElectronModule()?.dialog || null
}
export function getElectronSafeStorage(): any | null {
return getElectronModule()?.safeStorage || null
}
export function getElectronPath(name: string): string | null {
try {
const getter = getElectronApp()?.getPath
if (typeof getter === 'function') {
return getter(name)
}
} catch {
// fall through to caller fallback
}
return null
}
export function getAppPathFallback(): string {
try {
const getter = getElectronApp()?.getAppPath
if (typeof getter === 'function') {
return getter()
}
} catch {
// fall through
}
return process.cwd()
}
export function getPathFallback(name: string): string {
const fromElectron = getElectronPath(name)
if (fromElectron) return fromElectron
const home = homedir()
switch (name) {
case 'userData': {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) return workerUserDataPath
if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'WeFlow')
if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'WeFlow')
return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'WeFlow')
}
case 'documents':
return join(home, 'Documents')
case 'desktop':
return join(home, 'Desktop')
case 'downloads':
return join(home, 'Downloads')
case 'temp':
return tmpdir()
case 'appData':
return process.platform === 'win32' && process.env.APPDATA ? process.env.APPDATA : join(home, '.config')
default:
return process.cwd()
}
}
export function isElectronAppPackaged(): boolean {
const app = getElectronApp()
if (typeof app?.isPackaged === 'boolean') return app.isPackaged
return Boolean((process as any).resourcesPath && process.env.NODE_ENV !== 'development')
}

View File

@@ -1,6 +1,6 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { getPathFallback } from './electronRuntime'
export interface ExportRecord {
exportTime: number
@@ -20,7 +20,7 @@ class ExportRecordService {
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
const userDataPath = workerUserDataPath || getPathFallback('userData')
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
import * as path from 'path'
import { rm, rmdir } from 'fs/promises'
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
export interface ExportTaskControlHooks {
shouldPause: () => boolean
shouldStop: () => boolean
recordCreatedFile: (filePath: string) => void
recordCreatedDir: (dirPath: string) => void
}
interface ExportTaskManifest {
outputDir: string
files: Set<string>
dirs: Set<string>
}
interface ExportTaskControlRecord {
state: ExportTaskControlState
manifest: ExportTaskManifest
createdAt: number
updatedAt: number
}
export interface ExportTaskCleanupResult {
success: boolean
filesDeleted: number
dirsDeleted: number
error?: string
}
class ExportTaskControlService {
private tasks = new Map<string, ExportTaskControlRecord>()
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
this.registerTask(taskId, outputDir)
return {
shouldPause: () => this.getState(taskId) === 'pause_requested',
shouldStop: () => this.getState(taskId) === 'cancel_requested',
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
}
}
registerTask(taskId: string, outputDir: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
const existing = this.tasks.get(normalizedTaskId)
if (existing) {
existing.state = 'running'
existing.updatedAt = Date.now()
if (!existing.manifest.outputDir) {
existing.manifest.outputDir = normalizedOutputDir
}
return
}
this.tasks.set(normalizedTaskId, {
state: 'running',
manifest: {
outputDir: normalizedOutputDir,
files: new Set<string>(),
dirs: new Set<string>()
},
createdAt: Date.now(),
updatedAt: Date.now()
})
}
pauseTask(taskId: string): boolean {
return this.setState(taskId, 'pause_requested')
}
resumeTask(taskId: string): boolean {
return this.setState(taskId, 'running')
}
cancelTask(taskId: string): boolean {
return this.setState(taskId, 'cancel_requested')
}
getState(taskId: string): ExportTaskControlState | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
return this.tasks.get(normalizedTaskId)?.state || null
}
releaseTask(taskId: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
this.tasks.delete(normalizedTaskId)
}
recordCreatedFile(taskId: string, filePath: string): void {
const task = this.getTaskForManifestWrite(taskId, filePath)
if (!task) return
task.manifest.files.add(path.resolve(filePath))
task.updatedAt = Date.now()
}
recordCreatedDir(taskId: string, dirPath: string): void {
const task = this.getTaskForManifestWrite(taskId, dirPath)
if (!task) return
task.manifest.dirs.add(path.resolve(dirPath))
task.updatedAt = Date.now()
}
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
const normalizedTaskId = this.normalizeTaskId(taskId)
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
if (!task) {
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
}
const outputDir = task.manifest.outputDir
let filesDeleted = 0
let dirsDeleted = 0
const errors: string[] = []
const files = Array.from(task.manifest.files)
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
.sort((a, b) => b.length - a.length)
for (const filePath of files) {
try {
await rm(filePath, { force: true, recursive: false })
filesDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') {
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
const dirs = Array.from(task.manifest.dirs)
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
.sort((a, b) => b.length - a.length)
for (const dirPath of dirs) {
try {
await rmdir(dirPath)
dirsDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
if (errors.length === 0) {
this.releaseTask(normalizedTaskId)
return { success: true, filesDeleted, dirsDeleted }
}
return {
success: false,
filesDeleted,
dirsDeleted,
error: errors.slice(0, 3).join('; ')
}
}
private setState(taskId: string, state: ExportTaskControlState): boolean {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return false
const task = this.tasks.get(normalizedTaskId)
if (!task) return false
task.state = state
task.updatedAt = Date.now()
return true
}
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
const task = this.tasks.get(normalizedTaskId)
if (!task) return null
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
return null
}
return task
}
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
const resolvedTarget = path.resolve(targetPath)
const resolvedOutputDir = path.resolve(outputDir)
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
private isSamePath(left: string, right: string): boolean {
const resolvedLeft = path.resolve(left)
const resolvedRight = path.resolve(right)
if (process.platform === 'win32') {
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
}
return resolvedLeft === resolvedRight
}
private normalizeTaskId(taskId: string): string {
return String(taskId || '').trim()
}
}
export const exportTaskControlService = new ExportTaskControlService()

View File

@@ -1,4 +1,3 @@
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
@@ -8,6 +7,7 @@ import crypto from 'crypto'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
import { getElectronBrowserWindow, getPathFallback, isElectronAppPackaged } from './electronRuntime'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
@@ -35,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
}
// 方法3: 打包后的路径
if (app?.isPackaged) {
if (isElectronAppPackaged()) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
@@ -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)
}
@@ -1448,7 +1475,7 @@ export class ImageDecryptService {
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
try {
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
const getter = (getElectronBrowserWindow() as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
if (typeof getter !== 'function') return []
const windows = getter()
if (!Array.isArray(windows)) return []
@@ -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 {
@@ -2054,14 +2191,7 @@ export class ImageDecryptService {
}
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
try {
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
if (typeof getter !== 'function') return null
const value = getter(name)
return typeof value === 'string' && value.trim() ? value : null
} catch {
return null
}
return getPathFallback(name)
}
private getUserDataPath(): string {

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

@@ -167,7 +167,7 @@ export class KeyServiceLinux {
await new Promise(r => setTimeout(r, 2000))
return await this.getDbKey(pid, onStatus)
return await this.getDbKey(pid, onStatus, timeoutMs)
} catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message
@@ -176,7 +176,7 @@ export class KeyServiceLinux {
}
}
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
try {
const helperPath = this.getHelperPath()
@@ -200,28 +200,56 @@ export class KeyServiceLinux {
}
return await new Promise((resolve) => {
const options = { name: 'WeFlow' }
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
const options = {
name: 'WeFlow',
env: {
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
}
}
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
let settled = false
const finish = (result: DbKeyResult) => {
if (settled) return
settled = true
clearTimeout(watchdog)
resolve(result)
}
const watchdog = setTimeout(() => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
onStatus?.(err, 2)
finish({ success: false, error: err })
}, timeoutMs + 30_000)
this.sudo.exec(command, options, (error, stdout) => {
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
this.sudo.exec(command, options, (error, stdout, stderr) => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
if (error) {
onStatus?.('授权失败或被取消', 2)
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
const detail = String(stderr || '').trim()
const message = detail ? `${error.message}: ${detail}` : error.message
onStatus?.('授权失败或 Hook 执行失败', 2)
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
return
}
try {
const hookRes = JSON.parse((stdout as string).trim())
const output = String(stdout || '').trim()
if (!output) {
const detail = String(stderr || '').trim()
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
}
const hookRes = JSON.parse(output)
if (hookRes.success) {
onStatus?.('密钥获取成功', 1)
resolve({ success: true, key: hookRes.key })
finish({ success: true, key: hookRes.key })
} else {
onStatus?.(hookRes.result, 2)
resolve({ success: false, error: hookRes.result })
finish({ success: false, error: hookRes.result })
}
} catch (e) {
} catch (e: any) {
onStatus?.('解析 Hook 结果失败', 2)
resolve({ success: false, error: '解析 Hook 结果失败' })
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
}
})
})

View File

@@ -707,7 +707,7 @@ export class KeyServiceMac {
}
if (code === 'HOOK_FAILED') {
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。'
}
if (normalizedDetail.includes('attach_wait_timeout')) {
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'

View File

@@ -1,6 +1,5 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface SessionMessageCacheEntry {

View File

@@ -6,11 +6,30 @@ type NativeDecryptResult = {
ext: string
isWxgf?: boolean
is_wxgf?: boolean
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
export type NativeDatMeta = {
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string) => Buffer
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
}
let cachedAddon: NativeAddon | null | undefined
@@ -92,7 +111,7 @@ export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
): { data: Buffer; ext: string; isWxgf: boolean; meta: NativeDatMeta } | null {
const addon = loadAddon()
if (!addon) return null
@@ -104,7 +123,14 @@ export function decryptDatViaNative(
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
return { data: result.data, ext, isWxgf }
const meta: NativeDatMeta = {
version: result.version,
aes_size: result.aes_size ?? result.aesSize,
xor_size: result.xor_size ?? result.xorSize,
raw_size: result.raw_size ?? result.rawSize,
flag: result.flag
}
return { data: result.data, ext, isWxgf, meta }
} catch {
return null
}
@@ -113,13 +139,14 @@ export function decryptDatViaNative(
export function encryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
aesKey?: string,
meta?: NativeDatMeta
): Buffer | null {
const addon = loadAddon()
if (!addon || typeof addon.encryptDatNative !== 'function') return null
try {
const result = addon.encryptDatNative(inputPath, xorKey, aesKey)
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
return Buffer.isBuffer(result) ? result : null
} catch {
return null

View File

@@ -1340,6 +1340,8 @@ class SnsService {
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
shouldPause?: () => boolean
shouldStop?: () => boolean
recordCreatedFile?: (filePath: string) => void
recordCreatedDir?: (dirPath: string) => void
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
const { outputDir, format, usernames, keyword, startTime, endTime } = options
const hasExplicitMediaSelection =
@@ -1361,6 +1363,18 @@ class SnsService {
if (control?.shouldPause?.()) return 'paused'
return null
}
const ensureExportDir = (dirPath: string) => {
const existed = existsSync(dirPath)
if (!existed) {
mkdirSync(dirPath, { recursive: true })
control?.recordCreatedDir?.(dirPath)
}
}
const recordCreatedFileBeforeWrite = (filePath: string) => {
if (!existsSync(filePath)) {
control?.recordCreatedFile?.(filePath)
}
}
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
state === 'stopped'
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
@@ -1369,9 +1383,7 @@ class SnsService {
try {
// 确保输出目录存在
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
ensureExportDir(outputDir)
// 1. 分页加载全部帖子
const allPosts: SnsPost[] = []
@@ -1414,9 +1426,7 @@ class SnsService {
const mediaDir = join(outputDir, 'media')
if (shouldExportMedia) {
if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true })
}
ensureExportDir(mediaDir)
// 收集所有媒体下载任务
const mediaTasks: Array<{
@@ -1485,6 +1495,7 @@ class SnsService {
} else {
const result = await this.fetchAndDecryptImage(task.url, task.key)
if (result.success && result.data) {
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, result.data)
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
@@ -1494,6 +1505,7 @@ class SnsService {
mediaCount++
} else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath)
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, cachedData)
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
@@ -1531,7 +1543,7 @@ class SnsService {
// 2.5 下载头像
const avatarMap = new Map<string, string>()
if (format === 'html') {
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
ensureExportDir(mediaDir)
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
let avatarDone = 0
const avatarQueue = [...uniqueUsers]
@@ -1548,6 +1560,7 @@ class SnsService {
} else {
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
if (result.success && result.data) {
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, result.data)
avatarMap.set(post.username, `media/${fileName}`)
}
@@ -1602,6 +1615,7 @@ class SnsService {
linkUrl: (p as any).linkUrl
}))
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'arkmejson') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
@@ -1689,11 +1703,13 @@ class SnsService {
},
posts
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, html, 'utf-8')
}

View File

@@ -1,9 +1,9 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { getPathFallback } from './electronRuntime'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
@@ -45,7 +45,7 @@ class VideoService {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
const logDir = join(getPathFallback('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }

View File

@@ -1,9 +1,9 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
import { ConfigService } from './config'
import { getPathFallback } from './electronRuntime'
// Sherpa-onnx 类型定义
type OfflineRecognizer = any
@@ -91,7 +91,7 @@ export class VoiceTranscribeService {
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
return join(getPathFallback('documents'), 'WeFlow', 'models', 'sensevoice')
}
private resolveModelPath(fileName: string): string {

View File

@@ -92,6 +92,9 @@ export class WcdbService {
this.setPaths(this.resourcesPath, this.userDataPath)
}
this.setLogEnabled(this.logEnabled)
if (this.monitorListener) {
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
}
} catch (e) {
// Failed to create worker

8
package-lock.json generated
View File

@@ -22,7 +22,7 @@
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^1.7.0",
"react": "^19.2.5",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
@@ -8470,9 +8470,9 @@
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"

View File

@@ -35,7 +35,7 @@
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^1.7.0",
"react": "^19.2.5",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",

View File

@@ -80,7 +80,7 @@ import {
import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
type ContentCardType = ContentType | 'sns'
@@ -578,10 +578,27 @@ const formatDurationMs = (ms: number): string => {
const getTaskStatusLabel = (task: ExportTask): string => {
if (task.status === 'queued') return '排队中'
if (task.status === 'running') return '进行中'
if (task.status === 'pause_requested') return '暂停中'
if (task.status === 'paused') return '已暂停'
if (task.status === 'cancel_requested') return '取消中'
if (task.status === 'success') return '已完成'
return '失败'
}
const resolveExportTaskCardClass = (status: TaskStatus): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested') return 'stopped'
return status
}
const isExportTaskActiveStatus = (status: TaskStatus): boolean => (
status === 'queued' ||
status === 'running' ||
status === 'pause_requested' ||
status === 'paused' ||
status === 'cancel_requested'
)
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'running') return 'running'
if (status === 'pause_requested' || status === 'paused') return 'paused'
@@ -1809,6 +1826,9 @@ interface TaskCenterModalProps {
nowTick: number
onClose: () => void
onTogglePerfTask: (taskId: string) => void
onPauseExportTask: (taskId: string) => void
onResumeExportTask: (taskId: string) => void
onCancelExportTask: (taskId: string) => void
onPauseBackgroundTask: (taskId: string) => void
onResumeBackgroundTask: (taskId: string) => void
onCancelBackgroundTask: (taskId: string) => void
@@ -1824,6 +1844,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
nowTick,
onClose,
onTogglePerfTask,
onPauseExportTask,
onResumeExportTask,
onCancelExportTask,
onPauseBackgroundTask,
onResumeBackgroundTask,
onCancelBackgroundTask
@@ -1954,15 +1977,31 @@ const TaskCenterModal = memo(function TaskCenterModal({
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
)
: ''
const taskCardClass = resolveExportTaskCardClass(task.status)
const canShowProgress = (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)
const canPause = task.status === 'running'
const canResume = task.status === 'paused' || task.status === 'pause_requested'
const canCancel = (
task.status === 'queued' ||
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)
return (
<div key={task.id} className={`task-card ${task.status}`}>
<div key={task.id} className={`task-card ${taskCardClass}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{task.status === 'running' && (
{canShowProgress && (
<>
<div className="task-progress-bar">
<div
@@ -2050,6 +2089,34 @@ const TaskCenterModal = memo(function TaskCenterModal({
{isPerfExpanded ? '收起详情' : '性能详情'}
</button>
)}
{canPause && (
<button
className="task-action-btn"
type="button"
onClick={() => onPauseExportTask(task.id)}
>
<Pause size={14} />
</button>
)}
{canResume && (
<button
className="task-action-btn primary"
type="button"
onClick={() => onResumeExportTask(task.id)}
>
<Play size={14} />
</button>
)}
{canCancel && (
<button
className="task-action-btn danger"
type="button"
onClick={() => onCancelExportTask(task.id)}
disabled={task.status === 'cancel_requested'}
>
<Square size={14} /> {task.status === 'cancel_requested' ? '取消中' : '取消'}
</button>
)}
<button
className="task-action-btn"
onClick={() => {
@@ -5125,6 +5192,7 @@ function ExportPage() {
exportConcurrency: sourceOptions.exportConcurrency,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
exportWriteLayout: writeLayout,
sessionNameWithTypePrefix,
dateRange: sourceOptions.useAllTime
? null
@@ -5586,7 +5654,7 @@ function ExportPage() {
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateTask(next.id, task => {
if (task.status !== 'running') return task
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
const performance = applyProgressToTaskPerformance(task, payload, now)
const settledSessionIds = task.settledSessionIds || []
const nextSettledSessionIds = (
@@ -5740,7 +5808,8 @@ function ExportPage() {
exportLivePhotos: snsOptions.exportLivePhotos,
exportVideos: snsOptions.exportVideos,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
endTime: snsOptions.endTime,
taskId: next.id
})
if (!result.success) {
@@ -5751,6 +5820,19 @@ function ExportPage() {
error: result.error || '朋友圈导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else if (result.stopped) {
setTasks(prev => prev.filter(task => task.id !== next.id))
} else if (result.paused) {
updateTask(next.id, task => ({
...task,
status: 'paused',
progress: {
...task.progress,
phaseLabel: '已暂停,可继续或取消',
current: Math.max(task.progress.current, result.postCount || 0),
total: Math.max(task.progress.total, result.postCount || 0)
}
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
@@ -5782,7 +5864,8 @@ function ExportPage() {
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
next.payload.options,
{ taskId: next.id }
)
if (!result.success) {
@@ -5793,6 +5876,33 @@ function ExportPage() {
error: result.error || '导出失败',
performance: finalizeTaskPerformance(task, Date.now())
}))
} else if (result.stopped) {
setTasks(prev => prev.filter(task => task.id !== next.id))
} else if (result.paused) {
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
? result.pendingSessionIds
: []
updateTask(next.id, task => ({
...task,
status: 'paused',
payload: {
...task.payload,
sessionIds: pendingSessionIds.length > 0 ? pendingSessionIds : task.payload.sessionIds
},
settledSessionIds: Array.isArray(result.successSessionIds)
? Array.from(new Set([...(task.settledSessionIds || []), ...result.successSessionIds]))
: task.settledSessionIds,
sessionOutputPaths: {
...(task.sessionOutputPaths || {}),
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
? result.sessionOutputPaths
: {})
},
progress: {
...task.progress,
phaseLabel: '已暂停,可继续或取消'
}
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
@@ -5899,9 +6009,10 @@ function ExportPage() {
}
return {
...task.template.optionTemplate,
exportWriteLayout: task.template.optionTemplate.exportWriteLayout || writeLayout,
dateRange
}
}, [])
}, [writeLayout])
const enqueueAutomationTask = useCallback((
task: ExportAutomationTask,
@@ -5913,7 +6024,13 @@ function ExportPage() {
}
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
if (
item.status !== 'running' &&
item.status !== 'queued' &&
item.status !== 'pause_requested' &&
item.status !== 'paused' &&
item.status !== 'cancel_requested'
) return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
@@ -6200,7 +6317,7 @@ function ExportPage() {
const runningSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running') continue
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
const settled = new Set(task.settledSessionIds || [])
for (const id of task.payload.sessionIds) {
if (settled.has(id)) continue
@@ -6213,7 +6330,7 @@ function ExportPage() {
const queuedSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'queued') continue
if (task.status !== 'queued' && task.status !== 'paused') continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
@@ -6224,7 +6341,7 @@ function ExportPage() {
const inProgressSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
if (task.status !== 'running' && task.status !== 'queued') continue
if (!isExportTaskActiveStatus(task.status)) continue
for (const id of task.payload.sessionIds) {
set.add(id)
}
@@ -6232,7 +6349,7 @@ function ExportPage() {
return Array.from(set).sort()
}, [tasks])
const activeTaskCount = useMemo(
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
[tasks]
)
@@ -6247,7 +6364,7 @@ function ExportPage() {
if (previousStatus === task.status) continue
const now = Date.now()
if (task.status === 'running') {
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
patchAutomationTask(automationTaskId, (current) => ({
...current,
updatedAt: now,
@@ -6338,7 +6455,13 @@ function ExportPage() {
if (task.runState?.lastScheduleKey === scheduleKey) continue
const hasConflict = tasksRef.current.some((item) => {
if (item.status !== 'running' && item.status !== 'queued') return false
if (
item.status !== 'running' &&
item.status !== 'queued' &&
item.status !== 'pause_requested' &&
item.status !== 'paused' &&
item.status !== 'cancel_requested'
) return false
return item.payload.automationTaskId === task.id
})
if (hasConflict) {
@@ -6448,7 +6571,7 @@ function ExportPage() {
const runningCardTypes = useMemo(() => {
const set = new Set<ContentCardType>()
for (const task of tasks) {
if (task.status !== 'running') continue
if (!isExportTaskActiveStatus(task.status)) continue
if (task.payload.scope === 'sns') {
set.add('sns')
continue
@@ -7891,7 +8014,12 @@ function ExportPage() {
)
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskRunningCount = tasks.filter(task => (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)).length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const chatBackgroundTasks = useMemo(() => (
backgroundTasks.filter(task => task.sourcePage === 'chat')
@@ -8105,6 +8233,112 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const handlePauseExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task || task.status !== 'running') return
updateTask(taskId, current => ({
...current,
status: 'pause_requested',
progress: {
...current.progress,
phaseLabel: current.progress.phaseLabel || '暂停请求已发送'
}
}))
window.electronAPI.export.pauseTask(taskId).then(result => {
if (result.success) return
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : current.status,
error: result.error || '暂停请求失败'
}))
}).catch(error => {
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : current.status,
error: String(error)
}))
})
}, [updateTask])
const handleResumeExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task || (task.status !== 'paused' && task.status !== 'pause_requested')) return
window.electronAPI.export.resumeTask(taskId).then(result => {
const doneAt = Date.now()
if (!result.success) {
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: result.error || '继续任务失败',
performance: finalizeTaskPerformance(current, doneAt)
}))
return
}
updateTask(taskId, current => ({
...current,
status: current.status === 'pause_requested' ? 'running' : 'queued',
finishedAt: undefined,
error: undefined,
progress: {
...current.progress,
phaseLabel: current.status === 'pause_requested' ? '继续中' : '等待继续'
}
}))
}).catch(error => {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: String(error),
performance: finalizeTaskPerformance(current, doneAt)
}))
})
}, [updateTask])
const handleCancelExportTask = useCallback((taskId: string) => {
const task = tasksRef.current.find(item => item.id === taskId)
if (!task) return
if (task.status === 'queued') {
setTasks(prev => prev.filter(item => item.id !== taskId))
return
}
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'paused' && task.status !== 'cancel_requested') {
return
}
updateTask(taskId, current => ({
...current,
status: 'cancel_requested',
progress: {
...current.progress,
phaseLabel: '取消请求已发送,正在安全停止'
}
}))
window.electronAPI.export.cancelTask(taskId).then(result => {
if (result.success && task.status === 'paused') {
setTasks(prev => prev.filter(item => item.id !== taskId))
return
}
if (!result.success) {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: result.error || '取消任务失败',
performance: finalizeTaskPerformance(current, doneAt)
}))
}
}).catch(error => {
const doneAt = Date.now()
updateTask(taskId, current => ({
...current,
status: 'error',
finishedAt: doneAt,
error: String(error),
performance: finalizeTaskPerformance(current, doneAt)
}))
})
}, [updateTask])
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
const now = Date.now()
@@ -8564,6 +8798,9 @@ function ExportPage() {
nowTick={nowTick}
onClose={closeTaskCenter}
onTogglePerfTask={toggleTaskPerfDetail}
onPauseExportTask={handlePauseExportTask}
onResumeExportTask={handleResumeExportTask}
onCancelExportTask={handleCancelExportTask}
onPauseBackgroundTask={handlePauseBackgroundTask}
onResumeBackgroundTask={handleResumeBackgroundTask}
onCancelBackgroundTask={handleCancelBackgroundTask}
@@ -8622,12 +8859,12 @@ function ExportPage() {
<div className="automation-task-list">
{sortedAutomationTasks.map((task) => {
const linkedQueueTask = tasks.find((item) => (
(item.status === 'running' || item.status === 'queued') &&
isExportTaskActiveStatus(item.status) &&
item.payload.automationTaskId === task.id
))
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
? 'running'
: linkedQueueTask?.status === 'queued'
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
? 'queued'
: null
return (

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

@@ -6,7 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { dialog } from '../services/ipc'
import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import type { ChatSession, ContactInfo } from '../types/models'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
@@ -195,6 +195,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [launchAtStartup, setLaunchAtStartup] = useState(false)
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
const [silentStartup, setSilentStartup] = useState(false)
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
@@ -222,6 +223,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
const [isUpdatingSilentStartup, setIsUpdatingSilentStartup] = useState(false)
const [appVersion, setAppVersion] = useState('')
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const [showDecryptKey, setShowDecryptKey] = useState(false)
@@ -263,6 +265,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<SessionFilterTypeValue>('all')
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
const [antiRevokeSessions, setAntiRevokeSessions] = useState<ChatSession[]>([])
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
@@ -281,6 +284,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)
@@ -445,6 +451,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
const savedSilentStartup = await configService.getSilentStartup()
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
const savedQuoteLayout = await configService.getQuoteLayout()
const savedUpdateChannel = await configService.getUpdateChannel()
@@ -502,6 +509,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
setSilentStartup(savedSilentStartup)
setWindowCloseBehavior(savedWindowCloseBehavior)
setQuoteLayout(savedQuoteLayout)
if (savedUpdateChannel) {
@@ -544,6 +552,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()
@@ -568,6 +579,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)
@@ -615,6 +629,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
}
const handleSilentStartupChange = async (enabled: boolean) => {
if (isUpdatingSilentStartup) return
try {
setIsUpdatingSilentStartup(true)
await configService.setSilentStartup(enabled)
setSilentStartup(enabled)
showMessage(enabled ? '已开启静默启动' : '已关闭静默启动', true)
} catch (e: any) {
showMessage(`设置静默启动失败: ${e?.message || String(e)}`, false)
} finally {
setIsUpdatingSilentStartup(false)
}
}
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
try {
const result = await window.electronAPI.whisper?.getModelStatus()
@@ -752,10 +781,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
const getCurrentAntiRevokeSessionIds = (): string[] =>
normalizeSessionIds(chatSessions.map((session) => session.username))
normalizeSessionIds(antiRevokeSessions.map((session) => session.username))
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
const ensureChatSessionsLoaded = async (): Promise<string[]> => {
const current = normalizeSessionIds(chatSessions.map((session) => session.username))
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
@@ -765,6 +794,27 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
}
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
const current = getCurrentAntiRevokeSessionIds()
if (current.length > 0) return current
const sessionsResult = await window.electronAPI.chat.getAntiRevokeSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
throw new Error(sessionsResult.error || '加载会话失败')
}
const nextSessions = sessionsResult.sessions
const nextIds = normalizeSessionIds(nextSessions.map((session) => session.username))
setAntiRevokeSessions(nextSessions)
setAntiRevokeSelectedIds((prev) => {
const allowed = new Set(nextIds)
return new Set(Array.from(prev).filter((sessionId) => allowed.has(sessionId)))
})
setAntiRevokeStatusMap((prev) => {
const allowed = new Set(nextIds)
return Object.fromEntries(Object.entries(prev).filter(([sessionId]) => allowed.has(sessionId)))
})
return nextIds
}
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
setAntiRevokeStatusMap((prev) => {
const next = { ...prev }
@@ -976,11 +1026,10 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
let canceled = false
;(async () => {
try {
// 两个 Tab 都需要会话列表antiRevoke 还需要额外检查防撤回状态
const sessionIds = await ensureAntiRevokeSessionsLoaded()
if (canceled) return
if (activeTab === 'antiRevoke') {
await handleRefreshAntiRevokeStatus(sessionIds)
await ensureAntiRevokeSessionsLoaded()
} else {
await ensureChatSessionsLoaded()
}
} catch (e: any) {
if (!canceled) {
@@ -1684,6 +1733,35 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<div className="log-toggle-line">
<span className="log-status">
{isUpdatingSilentStartup
? '保存中...'
: (silentStartup ? '已开启' : '已关闭')}
</span>
<label className="switch" htmlFor="silent-startup-toggle">
<input
id="silent-startup-toggle"
className="switch-input"
type="checkbox"
checked={silentStartup}
disabled={isUpdatingSilentStartup}
onChange={(e) => {
void handleSilentStartupChange(e.target.checked)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint"></span>
@@ -1982,7 +2060,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
}
const renderAntiRevokeTab = () => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((session) => {
if (!keyword) return true
@@ -3012,6 +3090,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)
@@ -3205,7 +3301,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="form-hint">
N AI
<br />
<strong></strong>AI
<strong></strong>
<br />
<strong></strong> API
</span>
@@ -3226,7 +3322,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
</div>
{aiInsightAllowContext && (
<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">
@@ -3238,6 +3335,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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)
@@ -3246,7 +3344,57 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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" />
@@ -3285,7 +3433,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
)}
</div>
{aiInsightAllowSocialContext && (
<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">
@@ -3299,6 +3448,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
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)
@@ -3307,7 +3457,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
style={{ width: 100 }}
/>
</div>
)}
</div>
</div>
<div className="divider" />
{/* 自定义 System Prompt */}
@@ -3583,11 +3734,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
@@ -3626,8 +3780,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>
@@ -3702,9 +3872,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>
@@ -4761,4 +4931,3 @@ export default SettingsPage

View File

@@ -2729,6 +2729,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

@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
LAUNCH_AT_STARTUP: 'launchAtStartup',
SILENT_STARTUP: 'silentStartup',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
@@ -96,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',
@@ -131,6 +135,11 @@ export interface AiInsightWeiboBinding {
updatedAt: number
}
export interface AiInsightMomentsBinding {
enabled: boolean
updatedAt: number
}
export interface ExportDefaultMediaConfig {
images: boolean
videos: boolean
@@ -321,6 +330,17 @@ export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
}
// 获取静默启动偏好
export async function getSilentStartup(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.SILENT_STARTUP)
return value === true
}
// 设置静默启动偏好
export async function setSilentStartup(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.SILENT_STARTUP, enabled)
}
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
@@ -1910,6 +1930,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
@@ -2055,6 +2093,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

View File

@@ -35,6 +35,17 @@ export interface BackupOptions {
includeFiles?: boolean
}
export interface BackupImageDatMeta {
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
export interface BackupManifest {
version: 1
type: 'weflow-db-snapshots'
@@ -47,7 +58,7 @@ export interface BackupManifest {
options?: BackupOptions
databases: Array<{
id: string
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns'
kind: 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
dbPath: string
relativePath: string
tables: Array<{
@@ -70,6 +81,7 @@ export interface BackupManifest {
targetRelativePath: string
ext?: string
size?: number
datMeta?: BackupImageDatMeta
}>
videos?: Array<{
kind: 'image' | 'video' | 'file'
@@ -259,6 +271,7 @@ export interface ElectronAPI {
chat: {
connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getSessionStatuses: (usernames: string[]) => Promise<{
success: boolean
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
@@ -1079,16 +1092,22 @@ export interface ElectronAPI {
estimatedSeconds: number
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}>
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => Promise<{
success: boolean
successCount?: number
failCount?: number
paused?: boolean
stopped?: boolean
pendingSessionIds?: string[]
successSessionIds?: string[]
failedSessionIds?: string[]
failedSessionErrors?: Record<string, string>
sessionOutputPaths?: Record<string, string>
error?: string
}>
pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
resumeTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
cancelTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
success: boolean
error?: string
@@ -1161,7 +1180,8 @@ export interface ElectronAPI {
exportVideos?: boolean
startTime?: number
endTime?: number
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
taskId?: string
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
@@ -1250,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