mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-01 15:09:29 +00:00
Compare commits
47 Commits
dependabot
...
v4.4.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8946559d94 | ||
|
|
4a57a503f5 | ||
|
|
d53ddb0ba7 | ||
|
|
1fc710ccef | ||
|
|
82200e5fd7 | ||
|
|
bdf285062f | ||
|
|
b1807b21e7 | ||
|
|
32feac7d5e | ||
|
|
d2e59db123 | ||
|
|
d27cef6358 | ||
|
|
1f0b2613bf | ||
|
|
9c7ed1729a | ||
|
|
52f58f6288 | ||
|
|
dfe0186267 | ||
|
|
fd9b7c4546 | ||
|
|
9f9ad337ab | ||
|
|
c596d24083 | ||
|
|
6cfc38c33a | ||
|
|
13cede13f9 | ||
|
|
440c1f166a | ||
|
|
106d19fc6c | ||
|
|
60a4011539 | ||
|
|
fd97920fb2 | ||
|
|
55a7ce7b66 | ||
|
|
7469337aeb | ||
|
|
338d0e2f20 | ||
|
|
a86a51c30c | ||
|
|
043332d297 | ||
|
|
608f74a3f9 | ||
|
|
551d05fe2e | ||
|
|
c9317f76a3 | ||
|
|
ffd533d865 | ||
|
|
1976edc483 | ||
|
|
27690ee7fa | ||
|
|
81ade84a77 | ||
|
|
bb42a7c0b2 | ||
|
|
87d894b1f9 | ||
|
|
1b75986987 | ||
|
|
32aab8d490 | ||
|
|
8e2a6ec933 | ||
|
|
fc3356ece2 | ||
|
|
cd1ecf0ef6 | ||
|
|
9e6bf0f21a | ||
|
|
9ea34d74c2 | ||
|
|
42d4982728 | ||
|
|
f07e23b144 | ||
|
|
6cf67828a2 |
29
.github/scripts/release-utils.sh
vendored
29
.github/scripts/release-utils.sh
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/dev-daily-fixed.yml
vendored
2
.github/workflows/dev-daily-fixed.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/preview-nightly-main.yml
vendored
2
.github/workflows/preview-nightly-main.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -350,6 +350,8 @@ jobs:
|
||||
updpkgsums: true
|
||||
assets: |
|
||||
resources/installer/linux/weflow.desktop
|
||||
resources/installer/linux/icon.png
|
||||
resources/installer/linux/.gitignore
|
||||
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是可以正常交互的状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
|
||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||
6. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
|
||||
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
|
||||
### 常见报错与应对方法
|
||||
|
||||
@@ -50,4 +51,4 @@
|
||||
|
||||
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
||||
|
||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||
|
||||
@@ -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(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
)
|
||||
const onProgress = (progress: any) => queueProgress(progress)
|
||||
|
||||
const taskControl = config.taskId
|
||||
? {
|
||||
shouldPause: () => controlState.pauseRequested,
|
||||
shouldStop: () => controlState.stopRequested,
|
||||
recordCreatedFile: queueCreatedFile,
|
||||
recordCreatedDir: queueCreatedDir
|
||||
}
|
||||
: undefined
|
||||
|
||||
let result: any
|
||||
if (config.mode === 'contacts') {
|
||||
const { contactExportService } = await import('./services/contactExportService')
|
||||
result = await contactExportService.exportContacts(
|
||||
String(config.outputDir || ''),
|
||||
config.options || {}
|
||||
)
|
||||
} else if (config.mode === 'single') {
|
||||
result = await exportService.exportSessionToChatLab(
|
||||
String(config.sessionId || '').trim(),
|
||||
String(config.outputPath || '').trim(),
|
||||
config.options || { format: 'chatlab' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
} else {
|
||||
result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
}
|
||||
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
@@ -57,6 +199,8 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
|
||||
366
electron/main.ts
366
electron/main.ts
@@ -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'
|
||||
@@ -34,6 +34,7 @@ import { insightService } from './services/insightService'
|
||||
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
|
||||
import { bizService } from './services/bizService'
|
||||
import { backupService } from './services/backupService'
|
||||
import { imageDownloadService } from './services/imageDownloadService'
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -64,6 +65,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 +785,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 +2278,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 +2673,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(
|
||||
exportOptions,
|
||||
(progress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('sns:exportProgress', progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
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 +3014,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 +3091,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 +3111,12 @@ function registerIpcHandlers() {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
taskId,
|
||||
dbPath,
|
||||
decryptKey,
|
||||
myWxid,
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
@@ -3038,9 +3124,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 +3140,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 +3153,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 +3200,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 +3229,136 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const imageKeys = cfg.getImageKeysForCurrentWxid()
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'single',
|
||||
sessionId,
|
||||
outputPath,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
imageXorKey: imageKeys.xorKey,
|
||||
imageAesKey: imageKeys.aesKey,
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', msg.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-single] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||
return contactExportService.exportContacts(outputDir, options)
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
try {
|
||||
return await new Promise<any>((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
mode: 'contacts',
|
||||
outputDir,
|
||||
options,
|
||||
dbPath: String(cfg.get('dbPath') || '').trim(),
|
||||
decryptKey: String(cfg.get('decryptKey') || '').trim(),
|
||||
myWxid: String(cfg.get('myWxid') || '').trim(),
|
||||
resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
|
||||
userDataPath: app.getPath('userData'),
|
||||
logEnabled: cfg.get('logEnabled')
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalize = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalize(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
fail(String(msg.error || '导出 Worker 执行失败'))
|
||||
}
|
||||
})
|
||||
worker.on('error', fail)
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalize({ success: false, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
fail(`导出 Worker 异常退出: ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[export-worker-contacts] ${errorMessage}`)
|
||||
return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
|
||||
}
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
@@ -3697,6 +3955,19 @@ function registerIpcHandlers() {
|
||||
}
|
||||
})
|
||||
|
||||
// 自动下载原图
|
||||
ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => {
|
||||
return await imageDownloadService.startAutoDownload(whitelist || [])
|
||||
})
|
||||
|
||||
ipcMain.handle('image:stopAutoDownload', async () => {
|
||||
await imageDownloadService.stopAutoDownload()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('image:getAutoDownloadStatus', async () => {
|
||||
return await imageDownloadService.getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
// 主窗口引用
|
||||
@@ -3740,21 +4011,31 @@ function checkForUpdatesOnStartup() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||
createSplashWindow()
|
||||
// 先初始化配置,以便在启动早期判定是否需要静默启动
|
||||
configService = new ConfigService()
|
||||
applyAutoUpdateChannel('startup')
|
||||
syncLaunchAtStartupPreference()
|
||||
const onboardingDone = configService.get('onboardingDone') === true
|
||||
const startInBackground = onboardingDone && isSilentStartupEnabled()
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
if (splashWindow) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (splashWindow!.webContents.isLoading()) {
|
||||
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
if (!startInBackground) {
|
||||
// 非静默模式下显示 Splash,提供启动反馈
|
||||
createSplashWindow()
|
||||
|
||||
// 等待 Splash 页面加载完成后再推送进度
|
||||
if (splashWindow) {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (splashWindow!.webContents.isLoading()) {
|
||||
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
splashWindow.webContents
|
||||
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -3783,13 +4064,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()) {
|
||||
@@ -3820,6 +4095,13 @@ app.whenReady().then(async () => {
|
||||
// 注册 IPC 处理器
|
||||
updateSplashProgress(28, '正在初始化...')
|
||||
registerIpcHandlers()
|
||||
if (configService.get('autoDownloadHighRes')) {
|
||||
const whitelistArr = configService.get('autoDownloadWhitelist') || []
|
||||
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
|
||||
? (whitelistArr.join('\0') + '\0\0')
|
||||
: ''
|
||||
imageDownloadService.startAutoDownload(whitelistStr)
|
||||
}
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
insightService.handleDbMonitorChange(type, json)
|
||||
@@ -3956,6 +4238,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (!onboardingDone) {
|
||||
createOnboardingWindow()
|
||||
} else if (startInBackground && tray) {
|
||||
mainWindow?.hide()
|
||||
} else {
|
||||
mainWindow?.show()
|
||||
}
|
||||
@@ -3989,6 +4273,8 @@ const shutdownAppServices = async (): Promise<void> => {
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
try { await cloudControlService.stop() } catch {}
|
||||
// 停止自动下载服务
|
||||
try { await imageDownloadService.stopAutoDownload() } catch {}
|
||||
// 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调
|
||||
try { chatService.close() } catch {}
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
|
||||
@@ -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'),
|
||||
@@ -364,7 +365,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('image:decryptProgress', listener)
|
||||
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||
}
|
||||
},
|
||||
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
|
||||
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
|
||||
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
|
||||
},
|
||||
|
||||
// 视频
|
||||
@@ -373,6 +377,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
process: {
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||
@@ -462,8 +471,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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -666,6 +666,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 +712,7 @@ class ChatService {
|
||||
console.error('ChatService: 关闭数据库失败:', e)
|
||||
}
|
||||
this.connected = false
|
||||
this.monitorSetup = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -745,8 +749,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 +768,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 +787,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 +950,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 {
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ConfigSchema {
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
silentStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -84,7 +85,13 @@ interface ConfigSchema {
|
||||
aiInsightApiModel: string
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightAllowMomentsContext: boolean
|
||||
aiInsightMomentsContextCount: number
|
||||
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
|
||||
aiInsightAllowSocialContext: boolean
|
||||
aiInsightSocialContextCount: number
|
||||
aiInsightWeiboCookie: string
|
||||
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
|
||||
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||
aiInsightFilterList: string[]
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
@@ -109,6 +116,8 @@ interface ConfigSchema {
|
||||
aiFootprintSystemPrompt: string
|
||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||
aiInsightDebugLogEnabled: boolean
|
||||
autoDownloadHighRes: boolean
|
||||
autoDownloadWhitelist: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
@@ -163,6 +172,7 @@ export class ConfigService {
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false,
|
||||
silentStartup: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
@@ -203,6 +213,9 @@ export class ConfigService {
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightAllowMomentsContext: false,
|
||||
aiInsightMomentsContextCount: 5,
|
||||
aiInsightMomentsBindings: {},
|
||||
aiInsightAllowSocialContext: false,
|
||||
aiInsightFilterMode: 'whitelist',
|
||||
aiInsightFilterList: [],
|
||||
@@ -220,7 +233,9 @@ export class ConfigService {
|
||||
aiInsightWeiboBindings: {},
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false
|
||||
aiInsightDebugLogEnabled: false,
|
||||
autoDownloadHighRes: false,
|
||||
autoDownloadWhitelist: []
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
210
electron/services/exportTaskControlService.ts
Normal file
210
electron/services/exportTaskControlService.ts
Normal 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()
|
||||
@@ -81,6 +81,7 @@ export class ImageDecryptService {
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private nativeLogged = false
|
||||
private runtimeConfig: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null = null
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
private readonly accountDirCache = new Map<string, string>()
|
||||
@@ -99,6 +100,32 @@ export class ImageDecryptService {
|
||||
return this.shouldEmitImageEvents(payload)
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
|
||||
private getConfiguredDbPath(): string {
|
||||
return String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredMyWxid(): string {
|
||||
return String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
}
|
||||
|
||||
private getConfiguredImageKeys(): { xorKey: unknown; aesKey: string } {
|
||||
const runtimeImageXorKey = this.runtimeConfig?.imageXorKey
|
||||
const hasRuntimeXorKey = runtimeImageXorKey !== undefined && runtimeImageXorKey !== null && String(runtimeImageXorKey).trim() !== ''
|
||||
const runtimeAesKey = String(this.runtimeConfig?.imageAesKey || '').trim()
|
||||
if (hasRuntimeXorKey || runtimeAesKey) {
|
||||
const fallback = this.configService.getImageKeysForCurrentWxid()
|
||||
return {
|
||||
xorKey: hasRuntimeXorKey ? runtimeImageXorKey : fallback.xorKey,
|
||||
aesKey: runtimeAesKey || fallback.aesKey
|
||||
}
|
||||
}
|
||||
return this.configService.getImageKeysForCurrentWxid()
|
||||
}
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
@@ -266,8 +293,8 @@ export class ImageDecryptService {
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
@@ -294,8 +321,8 @@ export class ImageDecryptService {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
@@ -404,7 +431,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
|
||||
const imageKeys = this.configService.getImageKeysForCurrentWxid()
|
||||
const imageKeys = this.getConfiguredImageKeys()
|
||||
const xorKeyRaw = imageKeys.xorKey
|
||||
// 支持十六进制格式(如 0x53)和十进制格式
|
||||
let xorKey: number
|
||||
@@ -427,7 +454,7 @@ export class ImageDecryptService {
|
||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||
const aesKeyForNative = aesKeyText || undefined
|
||||
|
||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
@@ -527,8 +554,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDir(): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.getConfiguredMyWxid()
|
||||
const dbPath = this.getConfiguredDbPath()
|
||||
if (!wxid || !dbPath) return null
|
||||
return this.resolveAccountDir(dbPath, wxid)
|
||||
}
|
||||
@@ -1551,7 +1578,117 @@ export class ImageDecryptService {
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
if (result) return result
|
||||
const fallback = this.tryDecryptDatWithJs(datPath, xorKey, aesKey)
|
||||
if (fallback) {
|
||||
this.logInfo('JS DAT 解密 fallback 已启用', { datPath, ext: fallback.ext })
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private tryDecryptDatWithJs(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
try {
|
||||
const encrypted = readFileSync(datPath)
|
||||
const directExt = this.detectImageExtension(encrypted)
|
||||
if (directExt) return { data: encrypted, ext: directExt, isWxgf: false }
|
||||
|
||||
const candidates: Buffer[] = []
|
||||
const aesKeyText = String(aesKey || '').trim()
|
||||
const datVersion = this.getDatVersion(encrypted)
|
||||
if (datVersion === 2 && aesKeyText.length >= 16) {
|
||||
try {
|
||||
candidates.push(this.decryptDatV4WithJs(encrypted, xorKey, Buffer.from(aesKeyText, 'ascii').subarray(0, 16)))
|
||||
} catch { }
|
||||
}
|
||||
if (datVersion !== 2) {
|
||||
candidates.push(this.decryptDatV3WithJs(encrypted, xorKey))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const ext = this.detectImageExtension(candidate)
|
||||
if (ext) return { data: candidate, ext, isWxgf: false }
|
||||
}
|
||||
} catch (error) {
|
||||
this.logError('JS DAT 解密 fallback 失败', error, { datPath })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private decryptDatV3WithJs(data: Buffer, xorKey: number): Buffer {
|
||||
const output = Buffer.allocUnsafe(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
output[i] = data[i] ^ xorKey
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private decryptDatV4WithJs(data: Buffer, xorKey: number, aesKey: Buffer): Buffer {
|
||||
if (data.length < 0x0f) {
|
||||
throw new Error('dat file too small')
|
||||
}
|
||||
const header = data.subarray(0, 0x0f)
|
||||
const payload = data.subarray(0x0f)
|
||||
const aesSize = this.readInt32LeSafe(header, 6)
|
||||
const xorSize = this.readInt32LeSafe(header, 10)
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
if (alignedAesSize > payload.length) throw new Error('invalid aes size')
|
||||
|
||||
const aesData = payload.subarray(0, alignedAesSize)
|
||||
|
||||
let plainAes = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
||||
decipher.setAutoPadding(false)
|
||||
plainAes = this.strictRemovePkcs7Padding(Buffer.concat([decipher.update(aesData), decipher.final()]))
|
||||
}
|
||||
|
||||
const remaining = payload.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) throw new Error('invalid xor size')
|
||||
|
||||
let rawData = Buffer.alloc(0)
|
||||
let decodedXor = Buffer.alloc(0)
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) throw new Error('invalid raw size')
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
decodedXor = Buffer.allocUnsafe(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
decodedXor[i] = xorData[i] ^ xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
}
|
||||
return Buffer.concat([plainAes, rawData, decodedXor])
|
||||
}
|
||||
|
||||
private getDatVersion(data: Buffer): number {
|
||||
if (data.length < 6) return 0
|
||||
const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||
const sigV2 = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
if (data.subarray(0, 6).equals(sigV1)) return 1
|
||||
if (data.subarray(0, 6).equals(sigV2)) return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
private readInt32LeSafe(buffer: Buffer, offset: number): number {
|
||||
if (offset < 0 || offset + 4 > buffer.length) throw new Error('invalid int32 offset')
|
||||
return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)
|
||||
}
|
||||
|
||||
private strictRemovePkcs7Padding(data: Buffer): Buffer {
|
||||
if (data.length === 0) throw new Error('empty decrypted data')
|
||||
const pad = data[data.length - 1]
|
||||
if (pad <= 0 || pad > 16 || pad > data.length) throw new Error('invalid pkcs7 padding')
|
||||
for (let i = data.length - pad; i < data.length; i += 1) {
|
||||
if (data[i] !== pad) throw new Error('invalid pkcs7 padding')
|
||||
}
|
||||
return data.subarray(0, data.length - pad)
|
||||
}
|
||||
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
|
||||
203
electron/services/imageDownloadService.ts
Normal file
203
electron/services/imageDownloadService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
// import { ConfigService } from './config'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export class ImageDownloadService {
|
||||
private static instance: ImageDownloadService
|
||||
private koffi: any = null
|
||||
private lib: any = null
|
||||
private initialized = false
|
||||
|
||||
private initImgHelper: any = null
|
||||
private uninstallImgHelper: any = null
|
||||
private getImgHelperError: any = null
|
||||
|
||||
private currentPid: number | null = null
|
||||
private pollTimer: NodeJS.Timeout | null = null
|
||||
private isHooked = false
|
||||
|
||||
private lastWhitelist: string[] = []
|
||||
|
||||
static getInstance(): ImageDownloadService {
|
||||
if (!ImageDownloadService.instance) {
|
||||
ImageDownloadService.instance = new ImageDownloadService()
|
||||
}
|
||||
return ImageDownloadService.instance
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<boolean> {
|
||||
if (this.initialized) return true
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') return false
|
||||
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
if (!existsSync(dllPath)) return false
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
|
||||
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
|
||||
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
|
||||
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
|
||||
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[ImageDownloadService] failed to initialize:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
|
||||
} else {
|
||||
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
private async findMainWeChatPid(): Promise<number | null> {
|
||||
try {
|
||||
const script = `
|
||||
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
|
||||
Select-Object ProcessId, CommandLine |
|
||||
ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
|
||||
if (!stdout || !stdout.trim()) return null
|
||||
|
||||
let processes = JSON.parse(stdout.trim())
|
||||
if (!Array.isArray(processes)) processes = [processes]
|
||||
|
||||
const target = processes
|
||||
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
|
||||
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
|
||||
|
||||
return target ? target.ProcessId : null;
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
|
||||
if (!await this.ensureInitialized()) {
|
||||
return { success: false, error: '核心组件初始化失败' }
|
||||
}
|
||||
|
||||
if (this.isHooked) {
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
this.lastWhitelist = whitelist
|
||||
|
||||
if (!this.pollTimer) {
|
||||
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
|
||||
}
|
||||
|
||||
return await this.checkAndHook(whitelist, true)
|
||||
}
|
||||
|
||||
async stopAutoDownload() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = await this.findMainWeChatPid()
|
||||
|
||||
if (!pid) {
|
||||
if (this.isHooked) {
|
||||
console.log('[ImageDownloadService] WeChat exited, unhooking')
|
||||
await this.unhook()
|
||||
}
|
||||
return { success: true, error: '等待微信启动' }
|
||||
}
|
||||
|
||||
if (this.isHooked && this.currentPid === pid) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (this.isHooked && this.currentPid !== pid) {
|
||||
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
|
||||
try {
|
||||
let whitelistBuffer: Buffer | null = null;
|
||||
if (typeof whitelist === 'string') {
|
||||
if (whitelist.length > 0) {
|
||||
whitelistBuffer = Buffer.from(whitelist, 'utf8');
|
||||
}
|
||||
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
|
||||
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
|
||||
}
|
||||
|
||||
const success = this.initImgHelper(pid, whitelistBuffer)
|
||||
|
||||
if (success) {
|
||||
this.isHooked = true
|
||||
this.currentPid = pid
|
||||
console.log('[ImageDownloadService] hook successful')
|
||||
return { success: true }
|
||||
} else {
|
||||
const err = this.getImgHelperError()
|
||||
console.error(`[ImageDownloadService] hook failed: ${err}`)
|
||||
if (isManualStart && this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
return { success: false, error: err || 'Hook 失败' }
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
|
||||
if (isManualStart && this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
return { success: false, error: `调用异常: ${e.message || String(e)}` }
|
||||
}
|
||||
}
|
||||
|
||||
private async unhook() {
|
||||
if (this.isHooked && this.uninstallImgHelper) {
|
||||
try {
|
||||
this.uninstallImgHelper()
|
||||
} catch (e) {
|
||||
console.error('[ImageDownloadService] uninstall failed:', e)
|
||||
}
|
||||
}
|
||||
this.isHooked = false
|
||||
this.currentPid = null
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {
|
||||
isHooked: this.isHooked,
|
||||
pid: this.currentPid,
|
||||
supported: process.platform === 'win32' && process.arch === 'x64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageDownloadService = ImageDownloadService.getInstance()
|
||||
@@ -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',
|
||||
|
||||
@@ -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 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 阶段。请确认微信处于可交互状态并重试。'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1298
package-lock.json
generated
1298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.10",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
|
||||
1
resources/image/README.md
Normal file
1
resources/image/README.md
Normal file
@@ -0,0 +1 @@
|
||||
> 目前只适配了x64 win32平台,其它平台同样原理,但是代码还没写(
|
||||
BIN
resources/image/win32/x64/img_helper.dll
Normal file
BIN
resources/image/win32/x64/img_helper.dll
Normal file
Binary file not shown.
6
resources/installer/linux/.gitignore
vendored
Normal file
6
resources/installer/linux/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.zip
|
||||
src/
|
||||
pkg/
|
||||
weflow-*/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,8 +11,7 @@
|
||||
|
||||
.export-date-range-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
@@ -21,12 +20,14 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
@@ -35,6 +36,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-close-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
@@ -439,6 +460,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-btn {
|
||||
|
||||
@@ -565,6 +565,7 @@ export function ExportDateRangeDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-dialog-content">
|
||||
<div className="export-date-range-preset-list">
|
||||
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||
const active = isPresetActive(preset.value)
|
||||
@@ -728,6 +729,7 @@ export function ExportDateRangeDialog({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-dialog-actions">
|
||||
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -32,6 +32,7 @@ type SettingsTab =
|
||||
| 'aiCommon'
|
||||
| 'insight'
|
||||
| 'aiFootprint'
|
||||
| 'autoDownload'
|
||||
|
||||
const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
@@ -39,6 +40,7 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
|
||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'autoDownload', label: '自动下载', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||
@@ -47,6 +49,13 @@ const tabs: { id: Exclude<SettingsTab, 'insight' | 'aiFootprint'>; label: string
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
|
||||
const filteredTabs = tabs.filter(tab => {
|
||||
if (tab.id === 'autoDownload') {
|
||||
return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64'
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const aiTabs: Array<{ id: Extract<SettingsTab, 'aiCommon' | 'insight' | 'aiFootprint'>; label: string }> = [
|
||||
{ id: 'aiCommon', label: '基础配置' },
|
||||
{ id: 'insight', label: 'AI 见解' },
|
||||
@@ -149,6 +158,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
|
||||
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
const [autoDownloadHighRes, setAutoDownloadHighRes] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||
@@ -195,6 +205,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 +233,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 +275,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 +294,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200)
|
||||
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
|
||||
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
|
||||
const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false)
|
||||
const [aiInsightMomentsContextCount, setAiInsightMomentsContextCount] = useState(5)
|
||||
const [aiInsightMomentsBindings, setAiInsightMomentsBindings] = useState<Record<string, configService.AiInsightMomentsBinding>>({})
|
||||
const [isTestingInsight, setIsTestingInsight] = useState(false)
|
||||
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
|
||||
@@ -312,6 +328,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
|
||||
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
|
||||
|
||||
// 自动下载图片
|
||||
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
|
||||
const [autoDownloadSelectedIds, setAutoDownloadSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [autoDownloadSearchKeyword, setAutoDownloadSearchKeyword] = useState('')
|
||||
|
||||
// 检查 Hello 可用性
|
||||
useEffect(() => {
|
||||
setHelloAvailable(isWindows)
|
||||
@@ -445,6 +466,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 +524,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
setSilentStartup(savedSilentStartup)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
setQuoteLayout(savedQuoteLayout)
|
||||
if (savedUpdateChannel) {
|
||||
@@ -521,9 +544,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setWordCloudExcludeWords(savedExcludeWords)
|
||||
setExcludeWordsInput(savedExcludeWords.join('\n'))
|
||||
|
||||
const savedAutoDownloadHighRes = await configService.getAutoDownloadHighRes()
|
||||
const savedAutoDownloadWhitelist = await configService.getAutoDownloadWhitelist()
|
||||
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
||||
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
||||
|
||||
setAutoDownloadHighRes(savedAutoDownloadHighRes)
|
||||
setAutoDownloadSelectedIds(new Set(savedAutoDownloadWhitelist))
|
||||
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
@@ -544,6 +570,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens()
|
||||
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
|
||||
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
|
||||
const savedAiInsightAllowMomentsContext = await configService.getAiInsightAllowMomentsContext()
|
||||
const savedAiInsightMomentsContextCount = await configService.getAiInsightMomentsContextCount()
|
||||
const savedAiInsightMomentsBindings = await configService.getAiInsightMomentsBindings()
|
||||
const savedAiInsightFilterMode = await configService.getAiInsightFilterMode()
|
||||
const savedAiInsightFilterList = await configService.getAiInsightFilterList()
|
||||
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
|
||||
@@ -568,6 +597,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setAiModelApiMaxTokens(savedAiModelApiMaxTokens)
|
||||
setAiInsightSilenceDays(savedAiInsightSilenceDays)
|
||||
setAiInsightAllowContext(savedAiInsightAllowContext)
|
||||
setAiInsightAllowMomentsContext(savedAiInsightAllowMomentsContext)
|
||||
setAiInsightMomentsContextCount(savedAiInsightMomentsContextCount)
|
||||
setAiInsightMomentsBindings(savedAiInsightMomentsBindings)
|
||||
setAiInsightFilterMode(savedAiInsightFilterMode)
|
||||
setAiInsightFilterList(new Set(savedAiInsightFilterList))
|
||||
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
|
||||
@@ -615,6 +647,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()
|
||||
@@ -665,6 +712,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
void refreshWhisperStatus(whisperModelDir)
|
||||
}, [whisperModelDir])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'autoDownload') {
|
||||
fetchAutoDownloadStatus()
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
if (autoDownloadHighRes) {
|
||||
interval = setInterval(fetchAutoDownloadStatus, 2000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}
|
||||
}, [activeTab, autoDownloadHighRes])
|
||||
|
||||
const getErrorMessage = (error: any): string => {
|
||||
const raw = typeof error?.message === 'string' ? error.message : String(error ?? '')
|
||||
const normalized = raw.replace(/^Error:\s*/i, '').trim()
|
||||
@@ -752,10 +814,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 +827,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 }
|
||||
@@ -972,15 +1055,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
|
||||
if (activeTab !== 'antiRevoke' && activeTab !== 'insight' && activeTab !== 'autoDownload') return
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
if (activeTab === 'antiRevoke') {
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
if (activeTab === 'antiRevoke' || activeTab === 'autoDownload') {
|
||||
await ensureAntiRevokeSessionsLoaded()
|
||||
} else {
|
||||
await ensureChatSessionsLoaded()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!canceled) {
|
||||
@@ -1539,6 +1621,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAutoDownloadStatus = async () => {
|
||||
try {
|
||||
const status = await (window as any).electronAPI.image.getAutoDownloadStatus()
|
||||
setAutoDownloadStatus(status)
|
||||
} catch (error) {
|
||||
console.error('获取自动下载状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppearanceTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="theme-mode-toggle">
|
||||
@@ -1684,6 +1775,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 +2102,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 +3132,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const isMomentsEnabledForSession = (sessionId: string): boolean => {
|
||||
return aiInsightMomentsBindings[sessionId]?.enabled === true
|
||||
}
|
||||
|
||||
const handleToggleMomentsBinding = async (sessionId: string, enabled: boolean) => {
|
||||
const nextBindings = { ...aiInsightMomentsBindings }
|
||||
if (enabled) {
|
||||
nextBindings[sessionId] = {
|
||||
enabled: true,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
} else {
|
||||
delete nextBindings[sessionId]
|
||||
}
|
||||
setAiInsightMomentsBindings(nextBindings)
|
||||
await configService.setAiInsightMomentsBindings(nextBindings)
|
||||
}
|
||||
|
||||
const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => {
|
||||
const draftUid = getWeiboBindingDraftValue(sessionId)
|
||||
setWeiboBindingLoadingSessionId(sessionId)
|
||||
@@ -3205,7 +3343,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="form-hint">
|
||||
开启后,触发见解时会将该联系人最近 N 条聊天记录发送给 AI,分析质量显著提升。
|
||||
<br />
|
||||
<strong>关闭时</strong>:AI 仅知道统计摘要(沉默天数等),输出质量较低。
|
||||
<strong>关闭时</strong>:不会发送聊天原文,输出质量较低。
|
||||
<br />
|
||||
<strong>开启时</strong>:聊天文本内容(不含图片、语音)会通过你配置的 API 发送给模型提供商。请确认你信任该服务商。
|
||||
</span>
|
||||
@@ -3226,27 +3364,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiInsightAllowContext && (
|
||||
<div className="form-group">
|
||||
<label>发送近期对话条数</label>
|
||||
<span className="form-hint">
|
||||
发送给 AI 的聊天记录最大条数。条数越多分析越准确,token 消耗也越多。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightContextCount}
|
||||
min={1}
|
||||
max={200}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
|
||||
setAiInsightContextCount(val)
|
||||
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div className={`insight-collapsible-setting ${aiInsightAllowContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowContext}>
|
||||
<div className="insight-collapsible-setting-inner">
|
||||
<div className="form-group">
|
||||
<label>发送近期对话条数</label>
|
||||
<span className="form-hint">
|
||||
发送给 AI 的聊天记录最大条数。条数越多分析越准确,token 消耗也越多。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightContextCount}
|
||||
min={1}
|
||||
max={200}
|
||||
disabled={!aiInsightAllowContext}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
|
||||
setAiInsightContextCount(val)
|
||||
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>允许发送近期朋友圈内容用于分析(实验性)</label>
|
||||
<span className="form-hint">
|
||||
开启后,可在下方列表为私聊联系人单独允许朋友圈补充分析。程序只会在触发见解时按需读取,不会做后台持续扫描。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiInsightAllowMomentsContext ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightAllowMomentsContext}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightAllowMomentsContext(val)
|
||||
await configService.setAiInsightAllowMomentsContext(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`insight-collapsible-setting ${aiInsightAllowMomentsContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowMomentsContext}>
|
||||
<div className="insight-collapsible-setting-inner">
|
||||
<div className="form-group">
|
||||
<label>发送近期朋友圈条数</label>
|
||||
<span className="form-hint">
|
||||
发送给 AI 的朋友圈最大条数。条数越多上下文越充分,token 消耗也越多。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightMomentsContextCount}
|
||||
min={1}
|
||||
max={20}
|
||||
disabled={!aiInsightAllowMomentsContext}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 5))
|
||||
setAiInsightMomentsContextCount(val)
|
||||
scheduleConfigSave('aiInsightMomentsContextCount', () => configService.setAiInsightMomentsContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
@@ -3285,29 +3475,32 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiInsightAllowSocialContext && (
|
||||
<div className="form-group">
|
||||
<label>发送近期社交平台内容条数</label>
|
||||
<span className="form-hint">
|
||||
当前仅支持微博最近发帖。
|
||||
<br />
|
||||
<strong>不建议超过 5,避免触发平台风控。</strong>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightSocialContextCount}
|
||||
min={1}
|
||||
max={5}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
|
||||
setAiInsightSocialContextCount(val)
|
||||
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<div className={`insight-collapsible-setting ${aiInsightAllowSocialContext ? 'expanded' : 'collapsed'}`} aria-hidden={!aiInsightAllowSocialContext}>
|
||||
<div className="insight-collapsible-setting-inner">
|
||||
<div className="form-group">
|
||||
<label>发送近期社交平台内容条数</label>
|
||||
<span className="form-hint">
|
||||
当前仅支持微博最近发帖。
|
||||
<br />
|
||||
<strong>不建议超过 5,避免触发平台风控。</strong>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightSocialContextCount}
|
||||
min={1}
|
||||
max={5}
|
||||
disabled={!aiInsightAllowSocialContext}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3))
|
||||
setAiInsightSocialContextCount(val)
|
||||
scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
{/* 自定义 System Prompt */}
|
||||
@@ -3583,11 +3776,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<>
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>对话({filteredSessions.length})</span>
|
||||
<span className="insight-moments-column-title">朋友圈</span>
|
||||
<span className="insight-social-column-title">社交平台(微博)</span>
|
||||
<span>状态</span>
|
||||
<span className="anti-revoke-status-column-title">状态</span>
|
||||
</div>
|
||||
{filteredSessions.map((session) => {
|
||||
const isSelected = aiInsightFilterList.has(session.username)
|
||||
const isPrivateSession = session.type === 'private'
|
||||
const isMomentsEnabled = isMomentsEnabledForSession(session.username)
|
||||
const weiboBinding = aiInsightWeiboBindings[session.username]
|
||||
const weiboDraftValue = getWeiboBindingDraftValue(session.username)
|
||||
const isBindingLoading = weiboBindingLoadingSessionId === session.username
|
||||
@@ -3626,8 +3822,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="desc">{getSessionFilterTypeLabel(session.type)}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="insight-moments-cell">
|
||||
{isPrivateSession ? (
|
||||
<label className="insight-moments-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMomentsEnabled}
|
||||
onChange={(e) => { void handleToggleMomentsBinding(session.username, e.target.checked) }}
|
||||
/>
|
||||
<span className="check-indicator" aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<span className="binding-feedback muted">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="insight-social-binding-cell">
|
||||
{session.type === 'private' ? (
|
||||
{isPrivateSession ? (
|
||||
<>
|
||||
<div className="insight-social-binding-input-wrap">
|
||||
<span className="binding-platform-chip">微博</span>
|
||||
@@ -3702,9 +3914,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="api-docs">
|
||||
<div className="api-item">
|
||||
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对符合黑白名单规则的活跃会话进行分析。<br />
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过约 2 秒防抖后,对符合黑白名单规则的活跃会话进行分析。<br />
|
||||
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
||||
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
||||
<strong>频率控制</strong> — 冷却期、沉默间隔、黑白名单均在本地判断,不额外发送给模型。<br />
|
||||
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
||||
</p>
|
||||
</div>
|
||||
@@ -4488,6 +4700,203 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAutoDownloadTab = () => {
|
||||
const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const keyword = autoDownloadSearchKeyword.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((session) => {
|
||||
if (!keyword) return true
|
||||
const displayName = String(session.displayName || '').toLowerCase()
|
||||
const username = String(session.username || '').toLowerCase()
|
||||
return displayName.includes(keyword) || username.includes(keyword)
|
||||
})
|
||||
const filteredSessionIds = filteredSessions.map((session) => session.username)
|
||||
const selectedCount = autoDownloadSelectedIds.size
|
||||
const selectedInFilteredCount = filteredSessionIds.filter((id) => autoDownloadSelectedIds.has(id)).length
|
||||
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
|
||||
const isHooked = autoDownloadStatus?.isHooked
|
||||
|
||||
const persistWhitelist = (ids: Set<string>) => {
|
||||
const whitelistArr = Array.from(ids)
|
||||
configService.setAutoDownloadWhitelist(whitelistArr)
|
||||
if (autoDownloadHighRes) {
|
||||
const whitelistStr = whitelistArr.length > 0 ? (whitelistArr.join('\0') + '\0\0') : '';
|
||||
(window as any).electronAPI.image.startAutoDownload(whitelistStr)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
const next = new Set(autoDownloadSelectedIds)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setAutoDownloadSelectedIds(next)
|
||||
persistWhitelist(next)
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
const next = new Set(autoDownloadSelectedIds)
|
||||
filteredSessionIds.forEach(id => next.add(id))
|
||||
setAutoDownloadSelectedIds(next)
|
||||
persistWhitelist(next)
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
const next = new Set<string>()
|
||||
setAutoDownloadSelectedIds(next)
|
||||
persistWhitelist(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-content anti-revoke-tab">
|
||||
{/* 顶部 Hero 区域保持不变 */}
|
||||
<div className="anti-revoke-hero" style={{ background: 'linear-gradient(110deg, var(--bg-primary) 0%, rgba(245, 158, 11, 0.1) 100%)', borderColor: 'rgba(245, 158, 11, 0.3)' }}>
|
||||
<div className="anti-revoke-hero-main">
|
||||
<span className="updates-chip" style={{ color: '#f59e0b', background: 'rgba(245, 158, 11, 0.15)', width: 'fit-content' }}>测试功能 (Test)</span>
|
||||
<h2 style={{ marginTop: '8px' }}>自动下载原图</h2>
|
||||
<p>强制微信在接收图片时下载高清原图。建议仅在必要会话中开启以节省流量和空间。</p>
|
||||
</div>
|
||||
<div className="anti-revoke-metrics">
|
||||
<div className={`anti-revoke-metric ${isHooked ? 'is-installed' : 'is-pending'}`}>
|
||||
<span className="label">服务状态</span>
|
||||
<span className="value" style={{ fontSize: '14px' }}>
|
||||
{isHooked ? '正在监控' : autoDownloadHighRes ? '等待连接' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric">
|
||||
<span className="label">已选会话</span>
|
||||
<span className="value">{selectedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-control-card">
|
||||
<div className="anti-revoke-toolbar">
|
||||
<div className="filter-search-box anti-revoke-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索联系人或群聊..."
|
||||
value={autoDownloadSearchKeyword}
|
||||
onChange={(e) => setAutoDownloadSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="anti-revoke-toolbar-actions">
|
||||
<div className="anti-revoke-btn-group">
|
||||
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={filteredSessionIds.length === 0 || allFilteredSelected}>
|
||||
全选
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={selectedCount === 0}>
|
||||
清空选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="anti-revoke-btn-group" style={{ marginLeft: '12px', paddingLeft: '12px', borderLeft: '1px solid var(--border-color)' }}>
|
||||
<label className="switch switch-md">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoDownloadHighRes}
|
||||
onChange={() => handleToggleAutoDownload(Array.from(autoDownloadSelectedIds))}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
|
||||
{autoDownloadHighRes ? '服务已开启' : '服务已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-batch-actions">
|
||||
<div className="anti-revoke-selected-count">
|
||||
<span>已选 <strong>{selectedCount}</strong> 个目标会话</span>
|
||||
<span style={{ opacity: 0.6 }}>(若不选则默认对所有聊天生效)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-list">
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>会话({filteredSessions.length})</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="anti-revoke-empty">{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}</div>
|
||||
) : (
|
||||
filteredSessions.map((session) => {
|
||||
const isSelected = autoDownloadSelectedIds.has(session.username)
|
||||
return (
|
||||
<div key={session.username} className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}>
|
||||
<label className="anti-revoke-row-main">
|
||||
<span className="anti-revoke-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelection(session.username)}
|
||||
/>
|
||||
<span className="check-indicator" aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
</span>
|
||||
<Avatar src={session.avatarUrl} name={session.displayName} size={30} />
|
||||
<div className="anti-revoke-row-text">
|
||||
<span className="name">{session.displayName || session.username}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="anti-revoke-row-status">
|
||||
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
|
||||
<i className="status-dot" aria-hidden="true" />
|
||||
{isSelected ? '已监控' : '未开启'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 风险提示部分保持不变 */}
|
||||
<div className="api-warning-modal" style={{ width: '100%', border: '1px solid rgba(239, 68, 68, 0.2)', marginTop: '16px', background: 'rgba(239, 68, 68, 0.02)', animation: 'none', boxShadow: 'none', position: 'static' }}>
|
||||
<div className="modal-header" style={{ border: 'none', padding: '12px 20px 0' }}>
|
||||
<Lock size={16} color="#ef4444" />
|
||||
<h3 style={{ fontSize: '13px', color: '#ef4444' }}>风险警告</h3>
|
||||
</div>
|
||||
<div className="modal-body" style={{ fontSize: '12px', color: 'var(--text-secondary)', padding: '8px 20px 12px' }}>
|
||||
此功能通过内存 Hook 修改微信行为,具有一定的风险。请尽量仅在白名单模式下针对必要会话开启。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const handleToggleAutoDownload = async (whitelist?: string[] | string) => {
|
||||
const newVal = !autoDownloadHighRes
|
||||
setAutoDownloadHighRes(newVal)
|
||||
|
||||
try {
|
||||
if (newVal) {
|
||||
let currentWhitelist: string[] | string = whitelist || Array.from(autoDownloadSelectedIds)
|
||||
if (Array.isArray(currentWhitelist)) {
|
||||
currentWhitelist = currentWhitelist.length > 0 ? (currentWhitelist.join('\0') + '\0\0') : ''
|
||||
}
|
||||
const result = await (window as any).electronAPI.image.startAutoDownload(currentWhitelist)
|
||||
if (result && !result.success) {
|
||||
// 如果底层明确返回了失败
|
||||
throw new Error(result.error || '启动自动下载服务失败')
|
||||
}
|
||||
showMessage('自动下载已开启,正在尝试连接微信', true)
|
||||
await fetchAutoDownloadStatus()
|
||||
} else {
|
||||
await (window as any).electronAPI.image.stopAutoDownload()
|
||||
showMessage('自动下载已关闭', true)
|
||||
setAutoDownloadStatus(null)
|
||||
}
|
||||
await configService.setAutoDownloadHighRes(newVal)
|
||||
} catch (e: any) {
|
||||
// 发生错误时,将开关拨回去
|
||||
setAutoDownloadHighRes(!newVal)
|
||||
showMessage(`操作失败: ${e.message || String(e)}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderUpdatesTab = () => {
|
||||
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
|
||||
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
|
||||
@@ -4622,7 +5031,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
<div className="settings-layout">
|
||||
<div className="settings-tabs" role="tablist" aria-label="设置项">
|
||||
{tabs.flatMap((tab) => {
|
||||
{filteredTabs.flatMap((tab) => {
|
||||
const row: React.ReactNode[] = [
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -4680,6 +5089,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{activeTab === 'aiCommon' && renderAiCommonTab()}
|
||||
{activeTab === 'insight' && renderInsightTab()}
|
||||
{activeTab === 'aiFootprint' && renderAiFootprintTab()}
|
||||
{activeTab === 'autoDownload' && renderAutoDownloadTab()}
|
||||
{activeTab === 'updates' && renderUpdatesTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
@@ -4761,4 +5171,3 @@ export default SettingsPage
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2015,6 +2015,7 @@
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
width: 480px;
|
||||
max-width: 92vw;
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -2062,6 +2063,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2729,6 +2733,54 @@
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-progress-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.export-progress-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 82px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color));
|
||||
background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary));
|
||||
color: #d9363e;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-result {
|
||||
|
||||
@@ -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">></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>
|
||||
|
||||
@@ -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',
|
||||
@@ -115,7 +119,9 @@ export const CONFIG_KEYS = {
|
||||
// AI 足迹
|
||||
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
|
||||
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt',
|
||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled'
|
||||
AI_INSIGHT_DEBUG_LOG_ENABLED: 'aiInsightDebugLogEnabled',
|
||||
AUTO_DOWNLOAD_HIGH_RES: 'autoDownloadHighRes',
|
||||
AUTO_DOWNLOAD_WHITELIST: 'autoDownloadWhitelist'
|
||||
} as const
|
||||
|
||||
export interface WxidConfig {
|
||||
@@ -131,6 +137,11 @@ export interface AiInsightWeiboBinding {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AiInsightMomentsBinding {
|
||||
enabled: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ExportDefaultMediaConfig {
|
||||
images: boolean
|
||||
videos: boolean
|
||||
@@ -321,6 +332,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 +1932,24 @@ export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
|
||||
}
|
||||
|
||||
export async function getAiInsightAllowMomentsContext(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiInsightAllowMomentsContext(allow: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_MOMENTS_CONTEXT, allow)
|
||||
}
|
||||
|
||||
export async function getAiInsightMomentsContextCount(): Promise<number> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT)
|
||||
return typeof value === 'number' && value > 0 ? value : 5
|
||||
}
|
||||
|
||||
export async function setAiInsightMomentsContextCount(count: number): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_CONTEXT_COUNT, count)
|
||||
}
|
||||
|
||||
export async function getAiInsightAllowSocialContext(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_SOCIAL_CONTEXT)
|
||||
return value === true
|
||||
@@ -2055,6 +2095,33 @@ export async function setAiInsightWeiboBindings(bindings: Record<string, AiInsig
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_WEIBO_BINDINGS, bindings)
|
||||
}
|
||||
|
||||
const normalizeAiInsightMomentsBindings = (value: unknown): Record<string, AiInsightMomentsBinding> => {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const result: Record<string, AiInsightMomentsBinding> = {}
|
||||
for (const [sessionIdRaw, bindingRaw] of Object.entries(value as Record<string, unknown>)) {
|
||||
const sessionId = String(sessionIdRaw || '').trim()
|
||||
if (!sessionId) continue
|
||||
if (!bindingRaw || typeof bindingRaw !== 'object') continue
|
||||
const bindingObj = bindingRaw as { enabled?: unknown; updatedAt?: unknown }
|
||||
if (bindingObj.enabled !== true) continue
|
||||
const updatedAtRaw = Number(bindingObj.updatedAt)
|
||||
result[sessionId] = {
|
||||
enabled: true,
|
||||
updatedAt: Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? Math.floor(updatedAtRaw) : Date.now()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getAiInsightMomentsBindings(): Promise<Record<string, AiInsightMomentsBinding>> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS)
|
||||
return normalizeAiInsightMomentsBindings(value)
|
||||
}
|
||||
|
||||
export async function setAiInsightMomentsBindings(bindings: Record<string, AiInsightMomentsBinding>): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_MOMENTS_BINDINGS, normalizeAiInsightMomentsBindings(bindings))
|
||||
}
|
||||
|
||||
export async function getAiFootprintEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
|
||||
return value === true
|
||||
@@ -2082,3 +2149,22 @@ export async function setAiInsightDebugLogEnabled(enabled: boolean): Promise<voi
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_DEBUG_LOG_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAutoDownloadHighRes(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAutoDownloadHighRes(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_HIGH_RES, enabled)
|
||||
}
|
||||
|
||||
export async function getAutoDownloadWhitelist(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST)
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
export async function setAutoDownloadWhitelist(list: string[]): Promise<void> {
|
||||
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
|
||||
await config.set(CONFIG_KEYS.AUTO_DOWNLOAD_WHITELIST, normalized)
|
||||
}
|
||||
|
||||
|
||||
27
src/types/electron.d.ts
vendored
27
src/types/electron.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -8,6 +8,77 @@ const handleElectronOnStart = (options: { reload: () => void }) => {
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const exportWorkerElectronShimPlugin = () => {
|
||||
const virtualId = 'virtual:weflow-export-worker-electron'
|
||||
const resolvedVirtualId = `\0${virtualId}`
|
||||
|
||||
return {
|
||||
name: 'weflow-export-worker-electron-shim',
|
||||
enforce: 'pre' as const,
|
||||
resolveId(id: string) {
|
||||
if (id === virtualId) return resolvedVirtualId
|
||||
return null
|
||||
},
|
||||
load(id: string) {
|
||||
if (id !== resolvedVirtualId) return null
|
||||
return `
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
const workerUserDataPath = () => String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
const appDataPath = () => {
|
||||
if (process.platform === 'win32' && process.env.APPDATA) return process.env.APPDATA
|
||||
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support')
|
||||
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
||||
}
|
||||
const getPath = (name) => {
|
||||
if (name === 'userData') return workerUserDataPath() || join(appDataPath(), 'WeFlow')
|
||||
if (name === 'documents') return join(homedir(), 'Documents')
|
||||
if (name === 'desktop') return join(homedir(), 'Desktop')
|
||||
if (name === 'downloads') return join(homedir(), 'Downloads')
|
||||
if (name === 'temp') return tmpdir()
|
||||
if (name === 'appData') return appDataPath()
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
export const app = {
|
||||
isPackaged: Boolean(process.resourcesPath && process.env.NODE_ENV !== 'development'),
|
||||
getPath,
|
||||
getAppPath: () => process.cwd(),
|
||||
getName: () => 'WeFlow',
|
||||
getVersion: () => process.env.npm_package_version || '0.0.0'
|
||||
}
|
||||
export const BrowserWindow = { getAllWindows: () => [] }
|
||||
export const dialog = { showMessageBox: async () => ({ response: 0, checkboxChecked: false }) }
|
||||
export const shell = { openExternal: async () => false, showItemInFolder: () => {} }
|
||||
export const ipcMain = { on: () => {}, handle: () => {}, removeHandler: () => {} }
|
||||
export const ipcRenderer = { sendSync: () => ({}) }
|
||||
export const safeStorage = {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value) => Buffer.from(String(value || ''), 'utf8'),
|
||||
decryptString: (value) => Buffer.isBuffer(value) ? value.toString('utf8') : Buffer.from(value).toString('utf8')
|
||||
}
|
||||
export const Notification = class {
|
||||
static isSupported() { return false }
|
||||
on() { return this }
|
||||
show() {}
|
||||
close() {}
|
||||
}
|
||||
export default { app, BrowserWindow, dialog, shell, ipcMain, ipcRenderer, safeStorage, Notification }
|
||||
`
|
||||
},
|
||||
transform(code: string, id: string) {
|
||||
if (!/\.[cm]?[jt]s$/.test(id)) return null
|
||||
if (!code.includes("'electron'") && !code.includes('"electron"')) return null
|
||||
const next = code
|
||||
.replace(/from\s+(['"])electron\1/g, `from '${virtualId}'`)
|
||||
.replace(/import\s*\(\s*(['"])electron\1\s*\)/g, `import('${virtualId}')`)
|
||||
.replace(/require\s*\(\s*(['"])electron\1\s*\)/g, `require('${virtualId}')`)
|
||||
return next === code ? null : { code: next, map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
server: {
|
||||
@@ -142,6 +213,7 @@ export default defineConfig({
|
||||
entry: 'electron/exportWorker.ts',
|
||||
onstart: handleElectronOnStart,
|
||||
vite: {
|
||||
plugins: [exportWorkerElectronShimPlugin()],
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user