mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-11 23:15:51 +00:00
Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599fd1af26 | ||
|
|
b9af7ffc8c | ||
|
|
726edfa850 | ||
|
|
ff33242887 | ||
|
|
102eb14b0b | ||
|
|
e57b9d07f1 | ||
|
|
3be90d00e5 | ||
|
|
efb5cd3586 | ||
|
|
86b1043134 | ||
|
|
36bed846b2 | ||
|
|
9d3d38fa7e | ||
|
|
ddf6b63aec | ||
|
|
079779c2c6 | ||
|
|
afa8bb5fe0 | ||
|
|
127668ae22 | ||
|
|
b00264d060 | ||
|
|
2e135587d4 | ||
|
|
571bffa923 | ||
|
|
bc355d43a0 | ||
|
|
e2a207be92 | ||
|
|
397cc888db | ||
|
|
22a2616534 | ||
|
|
d6c9a10766 | ||
|
|
657e8015b2 | ||
|
|
fc3612abb2 | ||
|
|
8d79a82ac2 | ||
|
|
234cf690f0 | ||
|
|
d768c8d08c | ||
|
|
e98e9cb7d9 | ||
|
|
8e8f1b3d22 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
d96000f0d9 | ||
|
|
dcad30bc39 | ||
|
|
73ee524d1f | ||
|
|
4af8334f50 | ||
|
|
43fed79204 | ||
|
|
b356814ebb | ||
|
|
0acad9927a | ||
|
|
5bc46fadfc | ||
|
|
3090306394 | ||
|
|
ec95a16c7a | ||
|
|
45d3f735a9 | ||
|
|
0734b64cc8 | ||
|
|
70ad21cb46 | ||
|
|
9181490d0f | ||
|
|
01fc5cd1a0 | ||
|
|
b12ffff310 | ||
|
|
835359edf8 | ||
|
|
88817cf95e | ||
|
|
88d41f6857 | ||
|
|
ec9c1bbbba | ||
|
|
f9313392f1 | ||
|
|
2db8af3668 | ||
|
|
c56ba6e0a1 | ||
|
|
f1dcc84991 | ||
|
|
e8aaae5616 | ||
|
|
45deb99e3d | ||
|
|
28f6f966b9 | ||
|
|
7bd569feca | ||
|
|
056f2c1833 | ||
|
|
b821d370f9 | ||
|
|
60248b28f8 | ||
|
|
d128bedffa | ||
|
|
489b545965 | ||
|
|
36533d07f8 | ||
|
|
625e4f8e6a | ||
|
|
c4774e1ce1 | ||
|
|
e1682f99d2 | ||
|
|
a23461bfce | ||
|
|
73fc36e63a | ||
|
|
4beddb7a62 | ||
|
|
b130165831 | ||
|
|
9adffc3cd7 | ||
|
|
a52619c4d5 | ||
|
|
cf40d3ad63 | ||
|
|
f7f6252d0b | ||
|
|
14a2475fb1 | ||
|
|
76a55998c2 | ||
|
|
1ec8d54e96 | ||
|
|
62395b275d | ||
|
|
57fad47f27 | ||
|
|
20c5381211 | ||
|
|
b8cd9a8c38 | ||
|
|
4335abe31b | ||
|
|
e5f7b54a7b | ||
|
|
ea1ef03b98 | ||
|
|
8d374d4f49 | ||
|
|
f910e17e53 | ||
|
|
35a76aa04f | ||
|
|
5fce21d799 | ||
|
|
a32696ee13 | ||
|
|
b573baec80 | ||
|
|
0d4feceffc | ||
|
|
92abe73f0a | ||
|
|
7fa26b0716 | ||
|
|
dc49bf3877 | ||
|
|
d825dada59 | ||
|
|
74a08732fe | ||
|
|
7033a77d71 | ||
|
|
3b26e0c014 | ||
|
|
81ec51be33 | ||
|
|
fbecda9f1e | ||
|
|
b6950d4027 | ||
|
|
f31327b528 | ||
|
|
c4c7df2608 | ||
|
|
b8bf29277a | ||
|
|
867f85e8f2 | ||
|
|
7fb98d764a | ||
|
|
792621d982 | ||
|
|
337fe21d18 | ||
|
|
c92b50b6ec | ||
|
|
f83117df20 | ||
|
|
b7b7260838 | ||
|
|
dd960d30ff | ||
|
|
89f3ec57f5 | ||
|
|
95f1e73a39 | ||
|
|
aa029fe113 | ||
|
|
5971757a28 | ||
|
|
1e16ea887b | ||
|
|
837f15c5e8 | ||
|
|
f71ff7392c | ||
|
|
97ba95e2be | ||
|
|
6aae23180f | ||
|
|
49e82e43e4 | ||
|
|
301c490893 | ||
|
|
93a9df48f4 | ||
|
|
209b91bfef | ||
|
|
1049f55118 | ||
|
|
ba7785a359 | ||
|
|
e6c821d3ee | ||
|
|
17a7741697 | ||
|
|
f00525d21a | ||
|
|
f5c79c1fab | ||
|
|
4fc0a92651 | ||
|
|
585ec39f8e | ||
|
|
a0189fdd0a | ||
|
|
ede31732b3 | ||
|
|
a60381522d | ||
|
|
64010ad86b | ||
|
|
e628154b78 | ||
|
|
e5baf5e994 | ||
|
|
05fdbab496 | ||
|
|
512b1f6455 | ||
|
|
5615d83f04 | ||
|
|
ee38918516 | ||
|
|
d1b8d86a20 | ||
|
|
25ef7c5d8a | ||
|
|
db429abf5b | ||
|
|
19d5ae7e15 | ||
|
|
fcbd613f4a | ||
|
|
5fae370c55 | ||
|
|
f2dbe6ee8f | ||
|
|
0175a6998b | ||
|
|
758de9949b | ||
|
|
81b8960d41 | ||
|
|
5b25619b24 | ||
|
|
62e23aaf23 | ||
|
|
aac8eed898 | ||
|
|
108980befb | ||
|
|
6a4cd00d51 | ||
|
|
a6c899c098 | ||
|
|
28170d31df | ||
|
|
ce8d272d6e | ||
|
|
0047685f54 | ||
|
|
2cc0fc64a4 | ||
|
|
67642cebfd | ||
|
|
327dc85d14 | ||
|
|
8c4f42bab1 | ||
|
|
40c29e494c | ||
|
|
0235ec7edc | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
a58518ccb5 | ||
|
|
cdd17d919e | ||
|
|
4580cef7f2 | ||
|
|
6f9c765bab | ||
|
|
5b56b2e0be | ||
|
|
b0cc811807 | ||
|
|
eb540d5c13 | ||
|
|
e308293cf6 | ||
|
|
9ed4659c5c | ||
|
|
f5f2b76914 | ||
|
|
551a065497 | ||
|
|
88d7e38d82 | ||
|
|
65e6cb22dd | ||
|
|
689a396f6e | ||
|
|
512ea84850 | ||
|
|
1542e583f7 | ||
|
|
c488dcc3c6 | ||
|
|
1594e0e24b | ||
|
|
1e1fa77621 | ||
|
|
3270c17514 | ||
|
|
e635942e3d | ||
|
|
64dc2858a7 | ||
|
|
d05496bb3d | ||
|
|
bb94553fff | ||
|
|
113216b7ba | ||
|
|
55181edaa8 | ||
|
|
4f01a7b577 | ||
|
|
ea21111037 | ||
|
|
fba9f1de42 | ||
|
|
7d0b8db7a6 | ||
|
|
12faa31e34 | ||
|
|
74945b1752 | ||
|
|
a7c66517d2 | ||
|
|
adf187ddf5 | ||
|
|
614d897dd2 | ||
|
|
ec9a7d68e6 | ||
|
|
79dd91b270 | ||
|
|
b26bcd7603 | ||
|
|
a65468191b | ||
|
|
4ed5271703 | ||
|
|
f2afe2a977 | ||
|
|
430b0f30c9 | ||
|
|
8aac9a795e | ||
|
|
b9405765f9 | ||
|
|
90856b3812 | ||
|
|
1e78af3c25 | ||
|
|
4be232b951 | ||
|
|
59d5c2762d | ||
|
|
21a55439ec | ||
|
|
c4a35f5c15 | ||
|
|
b42f761011 | ||
|
|
46e2e64e65 | ||
|
|
54ed35ffb3 | ||
|
|
7b8bd747ad | ||
|
|
3e379957e1 | ||
|
|
b64525487e | ||
|
|
e544f5c862 | ||
|
|
669759c52e | ||
|
|
4a13d3209b | ||
|
|
be069e9aed | ||
|
|
0b20ee1aa2 | ||
|
|
e4872a78f5 | ||
|
|
4692216325 | ||
|
|
69128062fe | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
6d652130e6 | ||
|
|
9e6f8077f7 | ||
|
|
40342ca824 | ||
|
|
71238d4a01 | ||
|
|
4da9f1e6cf | ||
|
|
93b55fe370 | ||
|
|
ee5e7d2586 | ||
|
|
4f4e09c3de | ||
|
|
d537d81f1c | ||
|
|
26c6700152 | ||
|
|
49fb96d7a3 | ||
|
|
d256ee5696 | ||
|
|
bd70a7bfa8 | ||
|
|
3fb09bad0d | ||
|
|
06079659af | ||
|
|
22d8049c2c | ||
|
|
5f6b0e8960 | ||
|
|
9b8da7774d | ||
|
|
eabed55a7a | ||
|
|
32cc74f99c | ||
|
|
ffc4cc3d96 | ||
|
|
007cf57efd | ||
|
|
c6dba71197 | ||
|
|
8aa162e294 | ||
|
|
51d6dec7ff | ||
|
|
f1b2762769 | ||
|
|
d126be2aa5 | ||
|
|
ea034ee76a | ||
|
|
39634a690c | ||
|
|
a7001eb6da | ||
|
|
71e3540f18 | ||
|
|
4ea4020faa | ||
|
|
78cadfd352 | ||
|
|
da15f829d3 | ||
|
|
bb60694013 | ||
|
|
b3758d2baf | ||
|
|
bc794e9a44 | ||
|
|
c80115d0f7 | ||
|
|
6277576249 | ||
|
|
2201d369fa | ||
|
|
9f4e4790f5 | ||
|
|
501e373e38 | ||
|
|
b2cf7c92d5 | ||
|
|
e92e13c045 | ||
|
|
f3dec958b0 | ||
|
|
c88a1c5848 | ||
|
|
0162769d22 | ||
|
|
fa55755921 | ||
|
|
cfa335564a | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
b5a371da87 |
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "dev"
|
||||
134
.github/workflows/anti-spam.yml
vendored
Normal file
134
.github/workflows/anti-spam.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Anti-Spam
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for spam
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const text = title + ' ' + body;
|
||||
|
||||
// 博彩/赌球类
|
||||
const gamblingPatterns = [
|
||||
/世界杯.*买球/, /买球.*世界杯/,
|
||||
/世界杯.*下注/, /世界杯.*竞猜/,
|
||||
/世界杯.*投注/, /世界杯.*押注/,
|
||||
/世界杯.*彩票/, /世界杯.*平台/,
|
||||
/世界杯.*app/, /世界杯.*软件/,
|
||||
/世界杯.*网站/, /世界杯.*网址/,
|
||||
/足球.*买球/, /买球.*足球/,
|
||||
/足球.*投注/, /足球.*押注/,
|
||||
/足球.*竞猜/, /足球.*平台/,
|
||||
/篮球.*买球/, /篮球.*投注/,
|
||||
/体育.*投注/, /体育.*竞猜/,
|
||||
/体育.*买球/, /体育.*押注/,
|
||||
/赌球/, /赌博.*网站/, /赌博.*平台/,
|
||||
/博彩/, /博彩.*网站/, /博彩.*平台/,
|
||||
/正规.*买球/, /官方.*买球/,
|
||||
/买球.*网站/, /买球.*app/,
|
||||
/买球.*软件/, /买球.*网址/,
|
||||
/买球.*平台/, /买球.*技巧/,
|
||||
/投注.*网站/, /投注.*平台/,
|
||||
/押注.*网站/, /押注.*平台/,
|
||||
/竞猜.*网站/, /竞猜.*平台/,
|
||||
/彩票.*网站/, /彩票.*平台/,
|
||||
/欧洲杯.*买球/, /欧冠.*买球/,
|
||||
/nba.*买球/, /nba.*投注/,
|
||||
];
|
||||
|
||||
// 色情/交友类
|
||||
const adultPatterns = [
|
||||
/约炮/, /一夜情/, /外围/,
|
||||
/包养/, /援交/, /陪聊/,
|
||||
/成人.*网站/, /成人.*视频/,
|
||||
/av.*网站/, /黄色.*网站/,
|
||||
];
|
||||
|
||||
// 贷款/金融诈骗类
|
||||
const financePatterns = [
|
||||
/秒到账.*贷款/, /无抵押.*贷款/,
|
||||
/征信.*贷款/, /黑户.*贷款/,
|
||||
/快速.*放款/, /私人.*放贷/,
|
||||
/刷单/, /兼职.*日入/, /兼职.*月入/,
|
||||
/网赚/, /躺赚/, /被动收入.*平台/,
|
||||
/虚拟货币.*投资/, /usdt.*投资/,
|
||||
/炒币.*平台/, /数字货币.*平台/,
|
||||
];
|
||||
|
||||
// 垃圾推广类
|
||||
const spamPromoPatterns = [
|
||||
/代刷/, /粉丝.*购买/, /涨粉/,
|
||||
/seo.*优化/, /快速排名/,
|
||||
/微商/, /代理.*招募/,
|
||||
];
|
||||
|
||||
// 账号特征检测(新账号 + 无 contribution)
|
||||
const allPatterns = [
|
||||
...gamblingPatterns,
|
||||
...adultPatterns,
|
||||
...financePatterns,
|
||||
...spamPromoPatterns,
|
||||
];
|
||||
|
||||
const isSpam = allPatterns.some(pattern => pattern.test(text));
|
||||
|
||||
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
|
||||
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
|
||||
|
||||
if (isSpam || suspiciousShort) {
|
||||
// 确保 spam label 存在
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'spam',
|
||||
color: 'e4e669',
|
||||
description: 'Spam issue'
|
||||
});
|
||||
} catch (e) {
|
||||
// label 已存在,忽略
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['spam']
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: 'spam'
|
||||
});
|
||||
|
||||
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
|
||||
}
|
||||
407
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
407
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,407 @@
|
||||
name: Dev Daily
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: dev-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_DEV_TAG: nightly-dev
|
||||
TARGET_BRANCH: dev
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Generate daily dev version
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Dev version: $DEV_VERSION"
|
||||
|
||||
- name: Recreate fixed prerelease
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dev-mac-arm64:
|
||||
needs: prepare
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 dev artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-linux:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux dev artifacts
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-win-x64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-win-arm64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
update-dev-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- dev-mac-arm64
|
||||
- dev-linux
|
||||
- dev-win-x64
|
||||
- dev-win-arm64
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${FIXED_DEV_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_DEV_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found, skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > dev_release_notes.md <<EOF
|
||||
## Daily Dev Build
|
||||
- 该发布页为 **开发版**。
|
||||
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
## 说明
|
||||
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
450
.github/workflows/preview-nightly-main.yml
vendored
Normal file
450
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,450 @@
|
||||
name: Preview Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_PREVIEW_TAG: nightly-preview
|
||||
TARGET_BRANCH: main
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.meta.outputs.should_build }}
|
||||
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Decide whether to build and generate preview version
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin main --depth=1
|
||||
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SHOULD_BUILD=true
|
||||
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||
SHOULD_BUILD=true
|
||||
else
|
||||
SHOULD_BUILD=false
|
||||
fi
|
||||
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
YEARLY_RUN_COUNT=1
|
||||
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||
|
||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||
|
||||
- name: Recreate fixed preview prerelease
|
||||
if: steps.meta.outputs.should_build == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
preview-mac-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 preview artifacts
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-linux:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-win-x64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-win-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
update-preview-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- preview-mac-arm64
|
||||
- preview-linux
|
||||
- preview-win-x64
|
||||
- preview-win-arm64
|
||||
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update preview release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${FIXED_PREVIEW_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
|
||||
if [ -z "$WINDOWS_ASSET" ]; then
|
||||
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > preview_release_notes.md <<EOF
|
||||
## Preview Nightly 说明
|
||||
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
184
.github/workflows/release.yml
vendored
184
.github/workflows/release.yml
vendored
@@ -10,29 +10,11 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark release as pre-release (building)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
# Create or update the release as a pre-release with a placeholder note
|
||||
if gh release view "$TAG" --repo "$REPO" > /dev/null 2>&1; then
|
||||
gh release edit "$TAG" --repo "$REPO" --prerelease --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建,完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
|
||||
else
|
||||
gh release create "$TAG" --repo "$REPO" --prerelease --title "$TAG" --notes $'## ⚠️ 正在自动构建中,请勿下载\n\n各平台安装包正在构建,完成后将自动更新本页面并正式发布。\n\n**请勿在此期间下载任何文件。**'
|
||||
fi
|
||||
|
||||
release-mac-arm64:
|
||||
runs-on: macos-14
|
||||
needs: prepare-release
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
@@ -49,6 +31,22 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -57,20 +55,39 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --mac --arm64 --publish always
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish always
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
|
||||
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||
fi
|
||||
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||
done
|
||||
|
||||
release-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: prepare-release
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
@@ -95,6 +112,7 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -105,9 +123,22 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
fi
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
needs: prepare-release
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
@@ -132,20 +163,33 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish always "-c.artifactName=\${productName}-\${version}-x64-Setup.\${ext}"
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
fi
|
||||
|
||||
release-windows-arm64:
|
||||
runs-on: windows-latest
|
||||
needs: prepare-release
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
@@ -170,16 +214,30 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish Windows arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish always -c.publish.channel=latest-arm64 "-c.artifactName=\${productName}-\${version}-arm64-Setup.\${ext}"
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
fi
|
||||
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -190,53 +248,6 @@ jobs:
|
||||
- release-windows-arm64
|
||||
|
||||
steps:
|
||||
- name: Fix latest.yml to point to x64 installer
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
VERSION="${TAG#v}"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
|
||||
# Find the x64 exe asset name
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
|
||||
if [ -z "$X64_ASSET" ]; then
|
||||
X64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||
fi
|
||||
|
||||
if [ -z "$X64_ASSET" ]; then
|
||||
echo "ERROR: Could not find x64 exe asset"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Downloading x64 installer: $X64_ASSET"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "$X64_ASSET" --dir /tmp/weflow-x64
|
||||
|
||||
SHA512_B64="$(sha512sum "/tmp/weflow-x64/$X64_ASSET" | awk '{print $1}' | xxd -r -p | base64 -w 0)"
|
||||
SIZE="$(stat -c%s "/tmp/weflow-x64/$X64_ASSET")"
|
||||
RELEASE_DATE="$(gh release view "$TAG" --repo "$REPO" --json publishedAt -q .publishedAt)"
|
||||
|
||||
cat > /tmp/latest.yml <<YMLEOF
|
||||
version: $VERSION
|
||||
files:
|
||||
- url: $X64_ASSET
|
||||
sha512: $SHA512_B64
|
||||
size: $SIZE
|
||||
path: $X64_ASSET
|
||||
sha512: $SHA512_B64
|
||||
releaseDate: '$RELEASE_DATE'
|
||||
YMLEOF
|
||||
|
||||
# Strip leading spaces (heredoc indentation)
|
||||
sed -i 's/^ //' /tmp/latest.yml
|
||||
cat /tmp/latest.yml
|
||||
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
echo "latest.yml updated successfully to point to $X64_ASSET"
|
||||
|
||||
- name: Generate release notes with platform download links
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -285,27 +296,18 @@ jobs:
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
|
||||
## 下载
|
||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||
- Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示(未知来源)
|
||||
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
||||
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
||||
- xattr -rd com.apple.quarantine /Applications/WeFlow.app
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||
EOF
|
||||
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
- name: Mark release as published (no longer pre-release)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --latest --draft=false --prerelease=false
|
||||
|
||||
96
.github/workflows/security-scan.yml
vendored
Normal file
96
.github/workflows/security-scan.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
workflow_dispatch: # 手动触发
|
||||
pull_request: # PR 时触发
|
||||
branches: [ main, dev ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Scan (${{ matrix.branch }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch:
|
||||
- main
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# 1. npm audit - 检查依赖漏洞
|
||||
- name: Dependency vulnerability audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: true
|
||||
|
||||
# 2. CodeQL 静态分析
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript, typescript
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||
|
||||
# 3. 密钥/敏感信息扫描
|
||||
- name: Secret scanning with Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# 动态获取所有分支并扫描
|
||||
scan-all-branches:
|
||||
name: Scan additional branches
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
echo "===== Auditing branch: $branch ====="
|
||||
git checkout "$branch" 2>/dev/null || continue
|
||||
# 尝试安装并审计
|
||||
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||
npm audit --audit-level=moderate 2>/dev/null || true
|
||||
done
|
||||
continue-on-error: true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -56,6 +56,8 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
!resources/wcdb/
|
||||
!resources/wcdb/**
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
@@ -70,4 +72,7 @@ resources/wx_send
|
||||
概述.md
|
||||
pnpm-lock.yaml
|
||||
/pnpm-workspace.yaml
|
||||
wechat-research-site
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
|
||||
23
.gitleaks.toml
Normal file
23
.gitleaks.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
title = "Gitleaks Config"
|
||||
|
||||
[extend]
|
||||
# 继承默认规则
|
||||
useDefault = true
|
||||
|
||||
# 排除误报路径
|
||||
[[rules]]
|
||||
id = "curl-auth-header"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''docs/HTTP-API\.md'''
|
||||
]
|
||||
regexes = [
|
||||
'''YOUR_TOKEN'''
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''src/pages/ChatPage\.tsx'''
|
||||
]
|
||||
4
.npmrc
4
.npmrc
@@ -1,3 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
registry=https://registry.npmjs.org
|
||||
|
||||
50
README.md
50
README.md
@@ -1,32 +1,23 @@
|
||||
# WeFlow
|
||||
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
||||
|
||||
---
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
<p align="center">
|
||||
<img src="app.png" alt="WeFlow" width="90%">
|
||||
<img src="app.png" 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-square" alt="Stargazers">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
</a>
|
||||
<!-- 第一行修复样式 -->
|
||||
<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>
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
@@ -45,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
|
||||
## 支持平台与设备
|
||||
|
||||
|
||||
| 平台 | 设备/架构 | 安装包 |
|
||||
|------|----------|--------|
|
||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
@@ -66,6 +55,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||
@@ -90,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
@@ -105,7 +94,6 @@ npm install
|
||||
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
## 致谢
|
||||
@@ -117,18 +105,16 @@ npm run dev
|
||||
|
||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
122
docs/HTTP-API.md
122
docs/HTTP-API.md
@@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
||||
|
||||
---
|
||||
|
||||
## 7. 访问导出媒体
|
||||
## 7. 朋友圈接口
|
||||
|
||||
### 7.1 获取朋友圈时间线
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/timeline
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||
| `keyword` | string | 否 | 关键词过滤(正文) |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
|
||||
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
|
||||
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
|
||||
```
|
||||
|
||||
媒体字段说明(`media=1`):
|
||||
|
||||
- `media[].url/thumb`:你应该优先直接使用的字段。
|
||||
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
|
||||
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
|
||||
- `media[].rawUrl/rawThumb`:原始朋友圈地址
|
||||
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
|
||||
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
|
||||
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
|
||||
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
|
||||
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
|
||||
|
||||
### 7.2 获取朋友圈发布者
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/usernames
|
||||
```
|
||||
|
||||
### 7.3 获取朋友圈导出统计
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/export/stats
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
|
||||
### 7.4 朋友圈媒体代理
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/media/proxy
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
|
||||
### 7.5 导出朋友圈
|
||||
|
||||
```http
|
||||
POST /api/v1/sns/export
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
|
||||
"format": "json",
|
||||
"usernames": "wxid_a,wxid_b",
|
||||
"keyword": "旅行",
|
||||
"exportMedia": true,
|
||||
"exportImages": true,
|
||||
"exportLivePhotos": true,
|
||||
"exportVideos": true,
|
||||
"start": "20250101",
|
||||
"end": "20251231"
|
||||
}
|
||||
```
|
||||
|
||||
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||
|
||||
### 7.6 朋友圈防删开关
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/block-delete/status
|
||||
POST /api/v1/sns/block-delete/install
|
||||
POST /api/v1/sns/block-delete/uninstall
|
||||
```
|
||||
|
||||
### 7.7 删除单条朋友圈
|
||||
|
||||
```http
|
||||
DELETE /api/v1/sns/post/{postId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 访问导出媒体
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
@@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
|
||||
---
|
||||
|
||||
## 8. 使用示例
|
||||
## 9. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
@@ -525,7 +641,7 @@ members = requests.get(
|
||||
|
||||
---
|
||||
|
||||
## 9. 注意事项
|
||||
## 10. 注意事项
|
||||
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
|
||||
@@ -5,6 +5,9 @@ interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
@@ -29,6 +32,11 @@ async function run() {
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
})
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
|
||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
|
||||
1058
electron/main.ts
1058
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
||||
|
||||
/**
|
||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
||||
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||
*/
|
||||
function enforceLocalDllPriority() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
||||
try {
|
||||
enforceLocalDllPriority()
|
||||
} catch (e) {
|
||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
||||
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -53,6 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
app: {
|
||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||
@@ -64,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||
},
|
||||
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
@@ -188,6 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||
checkAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
|
||||
installAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
|
||||
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
@@ -218,6 +230,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
getResourceMessages: (options?: {
|
||||
sessionId?: string
|
||||
types?: Array<'image' | 'video' | 'voice' | 'file'>
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
|
||||
getMediaStream: (options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getMediaStream', options),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||
@@ -230,6 +258,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||
getMyFootprintStats: (
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number,
|
||||
options?: {
|
||||
myWxid?: string
|
||||
privateSessionIds?: string[]
|
||||
groupSessionIds?: string[]
|
||||
mentionLimit?: number
|
||||
privateLimit?: number
|
||||
mentionMode?: 'text_at_me' | string
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
|
||||
exportMyFootprint: (
|
||||
beginTimestamp: number,
|
||||
endTimestamp: number,
|
||||
format: 'csv' | 'json',
|
||||
filePath: string
|
||||
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
|
||||
getSchema: (payload?: { sessionId?: string }) => ipcRenderer.invoke('chat:getSchema', payload),
|
||||
executeSQL: (payload: {
|
||||
kind: 'message' | 'contact' | 'biz'
|
||||
path?: string | null
|
||||
sql: string
|
||||
limit?: number
|
||||
}) => ipcRenderer.invoke('chat:executeSQL', payload),
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
@@ -242,10 +295,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
@@ -255,12 +320,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||
ipcRenderer.on('image:cacheResolved', listener)
|
||||
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||
},
|
||||
onDecryptProgress: (callback: (payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => void) => {
|
||||
const listener = (_: unknown, payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('image:decryptProgress', listener)
|
||||
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
@@ -410,7 +496,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
|
||||
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
|
||||
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
|
||||
onCacheMigrationProgress: (callback: (payload: any) => void) => {
|
||||
const listener = (_event: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('sns:cacheMigrationProgress', listener)
|
||||
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
biz: {
|
||||
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||
},
|
||||
|
||||
|
||||
@@ -426,5 +527,194 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
},
|
||||
|
||||
// AI 见解
|
||||
insight: {
|
||||
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
private_inbound_people?: number
|
||||
private_replied_people?: number
|
||||
private_outbound_people?: number
|
||||
private_reply_rate?: number
|
||||
mention_count?: number
|
||||
mention_group_count?: number
|
||||
}
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||
},
|
||||
|
||||
aiApi: {
|
||||
listConversations: (payload?: { page?: number; pageSize?: number }) =>
|
||||
ipcRenderer.invoke('ai:listConversations', payload),
|
||||
createConversation: (payload?: { title?: string }) =>
|
||||
ipcRenderer.invoke('ai:createConversation', payload),
|
||||
renameConversation: (payload: { conversationId: string; title: string }) =>
|
||||
ipcRenderer.invoke('ai:renameConversation', payload),
|
||||
deleteConversation: (conversationId: string) =>
|
||||
ipcRenderer.invoke('ai:deleteConversation', conversationId),
|
||||
listMessages: (payload: { conversationId: string; limit?: number }) =>
|
||||
ipcRenderer.invoke('ai:listMessages', payload),
|
||||
exportConversation: (payload: { conversationId: string }) =>
|
||||
ipcRenderer.invoke('ai:exportConversation', payload),
|
||||
getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'),
|
||||
executeTool: (payload: { name: string; args?: Record<string, any> }) =>
|
||||
ipcRenderer.invoke('ai:executeTool', payload),
|
||||
cancelToolTest: (payload?: { taskId?: string }) =>
|
||||
ipcRenderer.invoke('ai:cancelToolTest', payload)
|
||||
},
|
||||
|
||||
agentApi: {
|
||||
runStream: (payload: {
|
||||
mode?: 'chat' | 'sql'
|
||||
conversationId?: string
|
||||
userInput: string
|
||||
assistantId?: string
|
||||
activeSkillId?: string
|
||||
chatScope?: 'group' | 'private'
|
||||
sqlContext?: { schemaText?: string; targetHint?: string }
|
||||
}) => ipcRenderer.invoke('agent:runStream', payload),
|
||||
abort: (payload: { runId?: string; conversationId?: string }) =>
|
||||
ipcRenderer.invoke('agent:abort', payload),
|
||||
onStream: (callback: (payload: any) => void) => {
|
||||
const listener = (_: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('agent:stream', listener)
|
||||
return () => ipcRenderer.removeListener('agent:stream', listener)
|
||||
}
|
||||
},
|
||||
|
||||
assistantApi: {
|
||||
getAll: () => ipcRenderer.invoke('assistant:getAll'),
|
||||
getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id),
|
||||
create: (payload: any) => ipcRenderer.invoke('assistant:create', payload),
|
||||
update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload),
|
||||
delete: (id: string) => ipcRenderer.invoke('assistant:delete', id),
|
||||
reset: (id: string) => ipcRenderer.invoke('assistant:reset', id),
|
||||
getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'),
|
||||
getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'),
|
||||
importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd)
|
||||
},
|
||||
|
||||
skillApi: {
|
||||
getAll: () => ipcRenderer.invoke('skill:getAll'),
|
||||
getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id),
|
||||
create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd),
|
||||
update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload),
|
||||
delete: (id: string) => ipcRenderer.invoke('skill:delete', id),
|
||||
getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'),
|
||||
importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd)
|
||||
},
|
||||
|
||||
llmApi: {
|
||||
getConfig: () => ipcRenderer.invoke('llm:getConfig'),
|
||||
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) =>
|
||||
ipcRenderer.invoke('llm:setConfig', payload),
|
||||
listModels: () => ipcRenderer.invoke('llm:listModels')
|
||||
},
|
||||
|
||||
aiAnalysis: {
|
||||
listConversations: (payload?: { page?: number; pageSize?: number }) =>
|
||||
ipcRenderer.invoke('aiAnalysis:listConversations', payload),
|
||||
createConversation: (payload?: { title?: string }) =>
|
||||
ipcRenderer.invoke('aiAnalysis:createConversation', payload),
|
||||
deleteConversation: (conversationId: string) =>
|
||||
ipcRenderer.invoke('aiAnalysis:deleteConversation', conversationId),
|
||||
listMessages: (payload: { conversationId: string; limit?: number }) =>
|
||||
ipcRenderer.invoke('aiAnalysis:listMessages', payload),
|
||||
sendMessage: (payload: {
|
||||
conversationId: string
|
||||
userInput: string
|
||||
options?: {
|
||||
parentMessageId?: string
|
||||
persistUserMessage?: boolean
|
||||
assistantId?: string
|
||||
activeSkillId?: string
|
||||
chatScope?: 'group' | 'private'
|
||||
}
|
||||
}) => ipcRenderer.invoke('aiAnalysis:sendMessage', payload),
|
||||
retryMessage: (payload: { conversationId: string; userMessageId?: string }) =>
|
||||
ipcRenderer.invoke('aiAnalysis:retryMessage', payload),
|
||||
abortRun: (payload: { runId?: string; conversationId?: string }) =>
|
||||
ipcRenderer.invoke('aiAnalysis:abortRun', payload),
|
||||
onRunEvent: (callback: (payload: {
|
||||
runId: string
|
||||
conversationId: string
|
||||
stage: string
|
||||
ts: number
|
||||
message: string
|
||||
intent?: string
|
||||
round?: number
|
||||
toolName?: string
|
||||
status?: string
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}) => void) => {
|
||||
const listener = (_: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('aiAnalysis:runEvent', listener)
|
||||
return () => ipcRenderer.removeListener('aiAnalysis:runEvent', listener)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('aiApi', {
|
||||
listConversations: (payload?: { page?: number; pageSize?: number }) => ipcRenderer.invoke('ai:listConversations', payload),
|
||||
createConversation: (payload?: { title?: string }) => ipcRenderer.invoke('ai:createConversation', payload),
|
||||
renameConversation: (payload: { conversationId: string; title: string }) => ipcRenderer.invoke('ai:renameConversation', payload),
|
||||
deleteConversation: (conversationId: string) => ipcRenderer.invoke('ai:deleteConversation', conversationId),
|
||||
listMessages: (payload: { conversationId: string; limit?: number }) => ipcRenderer.invoke('ai:listMessages', payload),
|
||||
exportConversation: (payload: { conversationId: string }) => ipcRenderer.invoke('ai:exportConversation', payload),
|
||||
getToolCatalog: () => ipcRenderer.invoke('ai:getToolCatalog'),
|
||||
executeTool: (payload: { name: string; args?: Record<string, any> }) => ipcRenderer.invoke('ai:executeTool', payload),
|
||||
cancelToolTest: (payload?: { taskId?: string }) => ipcRenderer.invoke('ai:cancelToolTest', payload)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('agentApi', {
|
||||
runStream: (payload: {
|
||||
mode?: 'chat' | 'sql'
|
||||
conversationId?: string
|
||||
userInput: string
|
||||
assistantId?: string
|
||||
activeSkillId?: string
|
||||
chatScope?: 'group' | 'private'
|
||||
sqlContext?: { schemaText?: string; targetHint?: string }
|
||||
}) => ipcRenderer.invoke('agent:runStream', payload),
|
||||
abort: (payload: { runId?: string; conversationId?: string }) => ipcRenderer.invoke('agent:abort', payload),
|
||||
onStream: (callback: (payload: any) => void) => {
|
||||
const listener = (_: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('agent:stream', listener)
|
||||
return () => ipcRenderer.removeListener('agent:stream', listener)
|
||||
}
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('assistantApi', {
|
||||
getAll: () => ipcRenderer.invoke('assistant:getAll'),
|
||||
getConfig: (id: string) => ipcRenderer.invoke('assistant:getConfig', id),
|
||||
create: (payload: any) => ipcRenderer.invoke('assistant:create', payload),
|
||||
update: (payload: { id: string; updates: any }) => ipcRenderer.invoke('assistant:update', payload),
|
||||
delete: (id: string) => ipcRenderer.invoke('assistant:delete', id),
|
||||
reset: (id: string) => ipcRenderer.invoke('assistant:reset', id),
|
||||
getBuiltinCatalog: () => ipcRenderer.invoke('assistant:getBuiltinCatalog'),
|
||||
getBuiltinToolCatalog: () => ipcRenderer.invoke('assistant:getBuiltinToolCatalog'),
|
||||
importFromMd: (rawMd: string) => ipcRenderer.invoke('assistant:importFromMd', rawMd)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('skillApi', {
|
||||
getAll: () => ipcRenderer.invoke('skill:getAll'),
|
||||
getConfig: (id: string) => ipcRenderer.invoke('skill:getConfig', id),
|
||||
create: (rawMd: string) => ipcRenderer.invoke('skill:create', rawMd),
|
||||
update: (payload: { id: string; rawMd: string }) => ipcRenderer.invoke('skill:update', payload),
|
||||
delete: (id: string) => ipcRenderer.invoke('skill:delete', id),
|
||||
getBuiltinCatalog: () => ipcRenderer.invoke('skill:getBuiltinCatalog'),
|
||||
importFromMd: (rawMd: string) => ipcRenderer.invoke('skill:importFromMd', rawMd)
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('llmApi', {
|
||||
getConfig: () => ipcRenderer.invoke('llm:getConfig'),
|
||||
setConfig: (payload: { apiBaseUrl?: string; apiKey?: string; model?: string }) => ipcRenderer.invoke('llm:setConfig', payload),
|
||||
listModels: () => ipcRenderer.invoke('llm:listModels')
|
||||
})
|
||||
|
||||
450
electron/services/aiAgentService.ts
Normal file
450
electron/services/aiAgentService.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { URL } from 'url'
|
||||
import { ConfigService } from './config'
|
||||
import { aiAnalysisService, type AiAnalysisRunEvent } from './aiAnalysisService'
|
||||
|
||||
export interface TokenUsage {
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
totalTokens?: number
|
||||
}
|
||||
|
||||
export interface AgentRuntimeStatus {
|
||||
phase: 'idle' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'error' | 'aborted'
|
||||
round?: number
|
||||
currentTool?: string
|
||||
toolsUsed?: number
|
||||
updatedAt: number
|
||||
totalUsage?: TokenUsage
|
||||
}
|
||||
|
||||
export interface AgentStreamChunk {
|
||||
runId: string
|
||||
conversationId?: string
|
||||
type: 'content' | 'think' | 'tool_start' | 'tool_result' | 'status' | 'done' | 'error'
|
||||
content?: string
|
||||
thinkTag?: string
|
||||
thinkDurationMs?: number
|
||||
toolName?: string
|
||||
toolParams?: Record<string, unknown>
|
||||
toolResult?: unknown
|
||||
error?: string
|
||||
isFinished?: boolean
|
||||
usage?: TokenUsage
|
||||
status?: AgentRuntimeStatus
|
||||
}
|
||||
|
||||
export interface AgentRunPayload {
|
||||
mode?: 'chat' | 'sql'
|
||||
conversationId?: string
|
||||
userInput: string
|
||||
assistantId?: string
|
||||
activeSkillId?: string
|
||||
chatScope?: 'group' | 'private'
|
||||
sqlContext?: {
|
||||
schemaText?: string
|
||||
targetHint?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveAgentRun {
|
||||
runId: string
|
||||
mode: 'chat' | 'sql'
|
||||
conversationId?: string
|
||||
innerRunId?: string
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback = ''): string {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function buildApiUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function extractSqlText(raw: string): string {
|
||||
const text = normalizeText(raw)
|
||||
if (!text) return ''
|
||||
const fenced = text.match(/```(?:sql)?\s*([\s\S]*?)```/i)
|
||||
if (fenced?.[1]) return fenced[1].trim()
|
||||
return text
|
||||
}
|
||||
|
||||
class AiAgentService {
|
||||
private readonly config = ConfigService.getInstance()
|
||||
private readonly runs = new Map<string, ActiveAgentRun>()
|
||||
|
||||
private getSharedModelConfig(): { apiBaseUrl: string; apiKey: string; model: string } {
|
||||
return {
|
||||
apiBaseUrl: normalizeText(this.config.get('aiModelApiBaseUrl')),
|
||||
apiKey: normalizeText(this.config.get('aiModelApiKey')),
|
||||
model: normalizeText(this.config.get('aiModelApiModel'), 'gpt-4o-mini')
|
||||
}
|
||||
}
|
||||
|
||||
private emitStatus(
|
||||
run: ActiveAgentRun,
|
||||
onChunk: (chunk: AgentStreamChunk) => void,
|
||||
phase: AgentRuntimeStatus['phase'],
|
||||
extra?: Partial<AgentRuntimeStatus>
|
||||
): void {
|
||||
onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'status',
|
||||
status: {
|
||||
phase,
|
||||
updatedAt: Date.now(),
|
||||
...extra
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private mapRunEventToChunk(
|
||||
run: ActiveAgentRun,
|
||||
event: AiAnalysisRunEvent
|
||||
): AgentStreamChunk | null {
|
||||
run.innerRunId = event.runId
|
||||
run.conversationId = event.conversationId || run.conversationId
|
||||
if (event.stage === 'llm_round_started') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'think',
|
||||
content: event.message,
|
||||
thinkTag: 'round'
|
||||
}
|
||||
}
|
||||
if (event.stage === 'tool_start') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'tool_start',
|
||||
toolName: event.toolName,
|
||||
toolParams: (event.data || {}) as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
if (event.stage === 'tool_done' || event.stage === 'tool_error') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'tool_result',
|
||||
toolName: event.toolName,
|
||||
toolResult: event.data || { status: event.status, durationMs: event.durationMs }
|
||||
}
|
||||
}
|
||||
if (event.stage === 'completed') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'status',
|
||||
status: { phase: 'completed', updatedAt: Date.now() }
|
||||
}
|
||||
}
|
||||
if (event.stage === 'aborted') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'status',
|
||||
status: { phase: 'aborted', updatedAt: Date.now() }
|
||||
}
|
||||
}
|
||||
if (event.stage === 'error') {
|
||||
return {
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'status',
|
||||
status: { phase: 'error', updatedAt: Date.now() }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async callModel(payload: any, apiBaseUrl: string, apiKey: string): Promise<any> {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const body = JSON.stringify(payload)
|
||||
const urlObj = new URL(endpoint)
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestFn = urlObj.protocol === 'https:' ? https.request : http.request
|
||||
const req = requestFn({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString(),
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
}, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += String(chunk) })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data || '{}'))
|
||||
} catch (error) {
|
||||
reject(new Error(`AI 响应解析失败: ${String(error)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(45_000, () => {
|
||||
req.destroy()
|
||||
reject(new Error('AI 请求超时'))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async runStream(
|
||||
payload: AgentRunPayload,
|
||||
runtime: {
|
||||
onChunk: (chunk: AgentStreamChunk) => void
|
||||
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
|
||||
}
|
||||
): Promise<{ success: boolean; runId: string }> {
|
||||
const runId = randomUUID()
|
||||
const mode = payload.mode === 'sql' ? 'sql' : 'chat'
|
||||
const run: ActiveAgentRun = {
|
||||
runId,
|
||||
mode,
|
||||
conversationId: normalizeText(payload.conversationId) || undefined,
|
||||
aborted: false
|
||||
}
|
||||
this.runs.set(runId, run)
|
||||
|
||||
this.execute(run, payload, runtime).catch((error) => {
|
||||
runtime.onChunk({
|
||||
runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: String((error as Error)?.message || error),
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({
|
||||
success: false,
|
||||
runId,
|
||||
conversationId: run.conversationId,
|
||||
error: String((error as Error)?.message || error)
|
||||
})
|
||||
this.runs.delete(runId)
|
||||
})
|
||||
|
||||
return { success: true, runId }
|
||||
}
|
||||
|
||||
private async execute(
|
||||
run: ActiveAgentRun,
|
||||
payload: AgentRunPayload,
|
||||
runtime: {
|
||||
onChunk: (chunk: AgentStreamChunk) => void
|
||||
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
|
||||
}
|
||||
): Promise<void> {
|
||||
if (run.mode === 'sql') {
|
||||
await this.executeSqlMode(run, payload, runtime)
|
||||
return
|
||||
}
|
||||
this.emitStatus(run, runtime.onChunk, 'thinking')
|
||||
const result = await aiAnalysisService.sendMessage(
|
||||
normalizeText(payload.conversationId),
|
||||
normalizeText(payload.userInput),
|
||||
{
|
||||
assistantId: normalizeText(payload.assistantId),
|
||||
activeSkillId: normalizeText(payload.activeSkillId),
|
||||
chatScope: payload.chatScope === 'group' ? 'group' : 'private'
|
||||
},
|
||||
{
|
||||
onRunEvent: (event) => {
|
||||
const mapped = this.mapRunEventToChunk(run, event)
|
||||
if (mapped) runtime.onChunk(mapped)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (run.aborted) {
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: '任务已取消',
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({
|
||||
success: false,
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
error: '任务已取消'
|
||||
})
|
||||
this.runs.delete(run.runId)
|
||||
return
|
||||
}
|
||||
if (!result.success || !result.result) {
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: result.error || '执行失败',
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({
|
||||
success: false,
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
error: result.error || '执行失败'
|
||||
})
|
||||
this.runs.delete(run.runId)
|
||||
return
|
||||
}
|
||||
|
||||
run.conversationId = result.result.conversationId || run.conversationId
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'content',
|
||||
content: result.result.assistantText
|
||||
})
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'done',
|
||||
usage: result.result.usage,
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId })
|
||||
this.runs.delete(run.runId)
|
||||
}
|
||||
|
||||
private async executeSqlMode(
|
||||
run: ActiveAgentRun,
|
||||
payload: AgentRunPayload,
|
||||
runtime: {
|
||||
onChunk: (chunk: AgentStreamChunk) => void
|
||||
onFinished?: (result: { success: boolean; runId: string; conversationId?: string; error?: string }) => void
|
||||
}
|
||||
): Promise<void> {
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: '请先在设置 > AI 通用中配置模型',
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '模型未配置' })
|
||||
this.runs.delete(run.runId)
|
||||
return
|
||||
}
|
||||
this.emitStatus(run, runtime.onChunk, 'thinking')
|
||||
const schemaText = normalizeText(payload.sqlContext?.schemaText)
|
||||
const targetHint = normalizeText(payload.sqlContext?.targetHint)
|
||||
const systemPrompt = [
|
||||
'你是 WeFlow SQL Lab 助手。',
|
||||
'只输出一段只读 SQL。',
|
||||
'禁止输出解释、Markdown、注释、DML、DDL。'
|
||||
].join('\n')
|
||||
const userPrompt = [
|
||||
targetHint ? `目标数据源: ${targetHint}` : '',
|
||||
schemaText ? `可用 Schema:\n${schemaText}` : '',
|
||||
`需求: ${normalizeText(payload.userInput)}`
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
const res = await this.callModel({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
temperature: 0.1,
|
||||
stream: false
|
||||
}, apiBaseUrl, apiKey)
|
||||
|
||||
if (run.aborted) {
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: '任务已取消',
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: '任务已取消' })
|
||||
this.runs.delete(run.runId)
|
||||
return
|
||||
}
|
||||
|
||||
const rawContent = normalizeText(res?.choices?.[0]?.message?.content)
|
||||
const sql = extractSqlText(rawContent)
|
||||
const usage: TokenUsage = {
|
||||
promptTokens: Number(res?.usage?.prompt_tokens || 0),
|
||||
completionTokens: Number(res?.usage?.completion_tokens || 0),
|
||||
totalTokens: Number(res?.usage?.total_tokens || 0)
|
||||
}
|
||||
if (!sql) {
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'error',
|
||||
error: 'SQL 生成失败',
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({ success: false, runId: run.runId, conversationId: run.conversationId, error: 'SQL 生成失败' })
|
||||
this.runs.delete(run.runId)
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < sql.length; i += 36) {
|
||||
if (run.aborted) break
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'content',
|
||||
content: sql.slice(i, i + 36)
|
||||
})
|
||||
}
|
||||
runtime.onChunk({
|
||||
runId: run.runId,
|
||||
conversationId: run.conversationId,
|
||||
type: 'done',
|
||||
usage,
|
||||
isFinished: true
|
||||
})
|
||||
runtime.onFinished?.({ success: true, runId: run.runId, conversationId: run.conversationId })
|
||||
this.runs.delete(run.runId)
|
||||
}
|
||||
|
||||
async abort(payload: { runId?: string; conversationId?: string }): Promise<{ success: boolean }> {
|
||||
const runId = normalizeText(payload.runId)
|
||||
const conversationId = normalizeText(payload.conversationId)
|
||||
if (runId) {
|
||||
const run = this.runs.get(runId)
|
||||
if (run) {
|
||||
run.aborted = true
|
||||
if (run.mode === 'chat') {
|
||||
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
|
||||
}
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
for (const run of this.runs.values()) {
|
||||
if (run.conversationId !== conversationId) continue
|
||||
run.aborted = true
|
||||
if (run.mode === 'chat') {
|
||||
await aiAnalysisService.abortRun({ runId: run.innerRunId, conversationId: run.conversationId })
|
||||
}
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAgentService = new AiAgentService()
|
||||
|
||||
2673
electron/services/aiAnalysisService.ts
Normal file
2673
electron/services/aiAnalysisService.ts
Normal file
File diff suppressed because it is too large
Load Diff
30
electron/services/aiAnalysisSkills/base.md
Normal file
30
electron/services/aiAnalysisSkills/base.md
Normal file
@@ -0,0 +1,30 @@
|
||||
你是 WeFlow 的 AI 分析助手。
|
||||
|
||||
目标:
|
||||
- 精准完成用户在聊天数据上的查询、总结、分析、回忆任务。
|
||||
- 优先使用本地工具获取证据,禁止猜测或捏造。
|
||||
- 默认输出简洁中文,先给结论,再给关键依据。
|
||||
|
||||
工作原则:
|
||||
- Token 节约优先:默认只请求必要字段,只有用户明确需要或证据不足时再升级 detailLevel。
|
||||
- 先范围后细节:优先定位会话/时间范围,再拉取具体时间轴或消息。
|
||||
- 可解释性:最终结论尽量附带来源范围与统计口径。
|
||||
- 语音消息不能臆测:必须先拿语音 ID,再点名转写,再总结。
|
||||
- 联系人排行题(“谁聊得最多/最常联系”)命中 ai_query_top_contacts 后,必须直接给出“前N名+消息数”。
|
||||
- 除非用户明确要求,联系人排行默认不包含群聊和公众号。
|
||||
- 用户提到“最近/近期/lately/recent”但未给时间窗时,默认按近30天口径统计并写明口径。
|
||||
- 用户提到联系人简称(如“lr”)时,先把它当联系人缩写处理,优先命中个人会话,不要默认落到群聊。
|
||||
- 用户问“我和X聊了什么”时必须交付“主题总结”,不要贴原始逐条聊天流水。
|
||||
|
||||
Agent执行要求:
|
||||
- 用户输入直接进入推理,本地不做关键词分流,你自主决定工具计划。
|
||||
- 当用户说“今天凌晨/昨晚/某段时间的聊天”,优先调用 ai_query_time_window_activity。
|
||||
- 拿到活跃会话后,调用 ai_query_session_glimpse 对多个会话逐个抽样阅读,不要只读一个会话就停止。
|
||||
- 如果初步探索后用户目标仍模糊,主动提出 1 个关键澄清问题继续多轮对话。
|
||||
- 仅当你确认任务完成时,输出结束标记 `[[WF_DONE]]`,并紧跟 `<final_answer>...</final_answer>`。
|
||||
- 若还未完成,不要输出结束标记,继续调用工具。
|
||||
|
||||
语音处理硬规则:
|
||||
- 当用户涉及“语音内容”时,先调用 ai_list_voice_messages。
|
||||
- 让系统返回候选 ID 后,再调用 ai_transcribe_voice_messages 指定 ID。
|
||||
- 未转写成功的语音不可作为事实依据。
|
||||
@@ -0,0 +1,6 @@
|
||||
你会收到 conversation_summary(历史压缩摘要)。
|
||||
|
||||
使用方式:
|
||||
- 默认把摘要作为历史背景,不逐字复述。
|
||||
- 若摘要与最近消息冲突,以最近消息为准。
|
||||
- 若用户追问很久之前的细节,优先重新调用工具检索,不依赖旧记忆。
|
||||
@@ -0,0 +1,8 @@
|
||||
工具:ai_fetch_message_briefs
|
||||
|
||||
何时用:
|
||||
- 需要核对少量关键消息原文,避免全量展开。
|
||||
|
||||
调用建议:
|
||||
- 只传必要 items(sessionId + localId),每次少量(<=20)。
|
||||
- 默认 minimal;需要上下文再用 standard/full。
|
||||
@@ -0,0 +1,9 @@
|
||||
工具:ai_query_session_candidates
|
||||
|
||||
何时用:
|
||||
- 用户未明确具体会话,但给了关键词/关系词(如“老婆”“买车”)。
|
||||
|
||||
调用建议:
|
||||
- 首次调用 detailLevel=minimal。
|
||||
- 默认 limit 8~12,避免拉太多候选。
|
||||
- 当候选歧义较大时再升级 detailLevel=standard/full。
|
||||
@@ -0,0 +1,9 @@
|
||||
工具:ai_query_session_glimpse
|
||||
|
||||
何时用:
|
||||
- 已确定候选会话,需要“先看一点”理解上下文。
|
||||
|
||||
Agent策略:
|
||||
- 每个候选会话先抽样 6~20 条,按时间顺序阅读。
|
||||
- 不要只读一个会话就结束;优先覆盖多会话后再总结。
|
||||
- 如果出现明显分歧场景(工作/家庭/感情)需主动向用户确认分析目标。
|
||||
8
electron/services/aiAnalysisSkills/tool_source_refs.md
Normal file
8
electron/services/aiAnalysisSkills/tool_source_refs.md
Normal file
@@ -0,0 +1,8 @@
|
||||
工具:ai_query_source_refs
|
||||
|
||||
何时用:
|
||||
- 输出总结或分析后,用于来源说明与可解释卡片。
|
||||
|
||||
调用建议:
|
||||
- 默认 minimal 即可,输出 range/session_count/message_count/db_refs。
|
||||
- 只有排错或审计时再请求 full。
|
||||
@@ -0,0 +1,9 @@
|
||||
工具:ai_query_time_window_activity
|
||||
|
||||
何时用:
|
||||
- 用户提到“今天凌晨/昨晚/某个时间段”的聊天分析。
|
||||
|
||||
Agent策略:
|
||||
- 第一步必须先扫时间窗活跃会话,不要直接下结论。
|
||||
- 拿到活跃会话后,再调用 ai_query_session_glimpse 逐个会话抽样阅读。
|
||||
- 若用户目标仍不清晰,先追问 1 个关键澄清问题再继续。
|
||||
9
electron/services/aiAnalysisSkills/tool_timeline.md
Normal file
9
electron/services/aiAnalysisSkills/tool_timeline.md
Normal file
@@ -0,0 +1,9 @@
|
||||
工具:ai_query_timeline
|
||||
|
||||
何时用:
|
||||
- 回忆事件经过、梳理时间线、提取关键节点。
|
||||
|
||||
调用建议:
|
||||
- 默认 detailLevel=minimal。
|
||||
- 先小批次 limit(40~120),不够再分页 offset。
|
||||
- 需要引用原文证据时,可搭配 ai_fetch_message_briefs。
|
||||
9
electron/services/aiAnalysisSkills/tool_top_contacts.md
Normal file
9
electron/services/aiAnalysisSkills/tool_top_contacts.md
Normal file
@@ -0,0 +1,9 @@
|
||||
工具:ai_query_top_contacts
|
||||
|
||||
何时用:
|
||||
- 用户问“谁联系最密切”“谁聊得最多”“最常联系的是谁”。
|
||||
|
||||
调用建议:
|
||||
- 该问题优先调用本工具,而不是先跑时间轴。
|
||||
- 默认 detailLevel=minimal,limit 5~10。
|
||||
- 需要区分群聊时再设置 includeGroups=true。
|
||||
8
electron/services/aiAnalysisSkills/tool_topic_stats.md
Normal file
8
electron/services/aiAnalysisSkills/tool_topic_stats.md
Normal file
@@ -0,0 +1,8 @@
|
||||
工具:ai_query_topic_stats
|
||||
|
||||
何时用:
|
||||
- 用户问“多少、占比、趋势、对比”。
|
||||
|
||||
调用建议:
|
||||
- 仅在统计问题时调用,避免无谓聚合。
|
||||
- 默认 detailLevel=minimal;有统计追问再升到 standard/full。
|
||||
8
electron/services/aiAnalysisSkills/tool_voice_list.md
Normal file
8
electron/services/aiAnalysisSkills/tool_voice_list.md
Normal file
@@ -0,0 +1,8 @@
|
||||
工具:ai_list_voice_messages
|
||||
|
||||
何时用:
|
||||
- 用户提到“语音里说了什么”。
|
||||
|
||||
调用建议:
|
||||
- 第一步先拿 ID 清单,默认 detailLevel=minimal(仅 IDs)。
|
||||
- 如用户需要挑选依据,再用 standard/full 查看更多元数据。
|
||||
@@ -0,0 +1,9 @@
|
||||
工具:ai_transcribe_voice_messages
|
||||
|
||||
何时用:
|
||||
- 已明确拿到语音 ID,且用户需要读取语音内容。
|
||||
|
||||
调用建议:
|
||||
- 必须显式传 ids 或 items。
|
||||
- 单次控制在小批次(建议 <=5),失败可重试。
|
||||
- 转写成功后再参与总结;失败项单独标注,不混入结论。
|
||||
444
electron/services/aiAssistantService.ts
Normal file
444
electron/services/aiAssistantService.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export type AssistantChatType = 'group' | 'private'
|
||||
export type AssistantToolCategory = 'core' | 'analysis'
|
||||
|
||||
export interface AssistantSummary {
|
||||
id: string
|
||||
name: string
|
||||
systemPrompt: string
|
||||
presetQuestions: string[]
|
||||
allowedBuiltinTools?: string[]
|
||||
builtinId?: string
|
||||
applicableChatTypes?: AssistantChatType[]
|
||||
supportedLocales?: string[]
|
||||
}
|
||||
|
||||
export interface AssistantConfigFull extends AssistantSummary {}
|
||||
|
||||
export interface BuiltinAssistantInfo {
|
||||
id: string
|
||||
name: string
|
||||
systemPrompt: string
|
||||
applicableChatTypes?: AssistantChatType[]
|
||||
supportedLocales?: string[]
|
||||
imported: boolean
|
||||
}
|
||||
|
||||
const GENERAL_CN_MD = `---
|
||||
id: general_cn
|
||||
name: 通用分析助手
|
||||
supportedLocales:
|
||||
- zh
|
||||
presetQuestions:
|
||||
- 最近都在聊什么?
|
||||
- 谁是最活跃的人?
|
||||
- 帮我总结一下最近一周的重要聊天
|
||||
- 帮我找一下关于“旅游”的讨论
|
||||
allowedBuiltinTools:
|
||||
- ai_query_time_window_activity
|
||||
- ai_query_session_candidates
|
||||
- ai_query_session_glimpse
|
||||
- ai_query_timeline
|
||||
- ai_fetch_message_briefs
|
||||
- ai_list_voice_messages
|
||||
- ai_transcribe_voice_messages
|
||||
- ai_query_topic_stats
|
||||
- ai_query_source_refs
|
||||
- ai_query_top_contacts
|
||||
---
|
||||
|
||||
你是 WeFlow 的全局聊天分析助手。请使用工具获取证据,给出简洁、准确、可执行的结论。
|
||||
|
||||
输出要求:
|
||||
1. 先结论,再证据。
|
||||
2. 若证据不足,明确说明不足并建议下一步。
|
||||
3. 涉及语音内容时,必须先列语音 ID,再按 ID 转写。
|
||||
4. 默认中文输出,除非用户明确指定其他语言。`
|
||||
|
||||
const GENERAL_EN_MD = `---
|
||||
id: general_en
|
||||
name: General Analysis Assistant
|
||||
supportedLocales:
|
||||
- en
|
||||
presetQuestions:
|
||||
- What have people been discussing recently?
|
||||
- Who are the most active contacts?
|
||||
- Summarize my key chat topics this week
|
||||
allowedBuiltinTools:
|
||||
- ai_query_time_window_activity
|
||||
- ai_query_session_candidates
|
||||
- ai_query_session_glimpse
|
||||
- ai_query_timeline
|
||||
- ai_fetch_message_briefs
|
||||
- ai_list_voice_messages
|
||||
- ai_transcribe_voice_messages
|
||||
- ai_query_topic_stats
|
||||
- ai_query_source_refs
|
||||
- ai_query_top_contacts
|
||||
---
|
||||
|
||||
You are WeFlow's global chat analysis assistant.
|
||||
Always ground your answers in tool evidence, stay concise, and clearly call out uncertainty when data is insufficient.`
|
||||
|
||||
const GENERAL_JA_MD = `---
|
||||
id: general_ja
|
||||
name: 汎用分析アシスタント
|
||||
supportedLocales:
|
||||
- ja
|
||||
presetQuestions:
|
||||
- 最近どんな話題が多い?
|
||||
- 一番アクティブな相手は誰?
|
||||
- 今週の重要な会話を要約して
|
||||
allowedBuiltinTools:
|
||||
- ai_query_time_window_activity
|
||||
- ai_query_session_candidates
|
||||
- ai_query_session_glimpse
|
||||
- ai_query_timeline
|
||||
- ai_fetch_message_briefs
|
||||
- ai_list_voice_messages
|
||||
- ai_transcribe_voice_messages
|
||||
- ai_query_topic_stats
|
||||
- ai_query_source_refs
|
||||
- ai_query_top_contacts
|
||||
---
|
||||
|
||||
あなたは WeFlow のグローバルチャット分析アシスタントです。
|
||||
ツールから得た根拠に基づき、簡潔かつ正確に回答してください。`
|
||||
|
||||
const BUILTIN_ASSISTANTS = [
|
||||
{ id: 'general_cn', raw: GENERAL_CN_MD },
|
||||
{ id: 'general_en', raw: GENERAL_EN_MD },
|
||||
{ id: 'general_ja', raw: GENERAL_JA_MD }
|
||||
] as const
|
||||
|
||||
function normalizeText(value: unknown, fallback = ''): string {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function parseInlineList(text: string): string[] {
|
||||
const raw = normalizeText(text)
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function splitFrontmatter(raw: string): { frontmatter: string; body: string } {
|
||||
const normalized = String(raw || '')
|
||||
if (!normalized.startsWith('---')) {
|
||||
return { frontmatter: '', body: normalized.trim() }
|
||||
}
|
||||
const end = normalized.indexOf('\n---', 3)
|
||||
if (end < 0) return { frontmatter: '', body: normalized.trim() }
|
||||
return {
|
||||
frontmatter: normalized.slice(3, end).trim(),
|
||||
body: normalized.slice(end + 4).trim()
|
||||
}
|
||||
}
|
||||
|
||||
function parseAssistantMarkdown(raw: string): AssistantConfigFull {
|
||||
const { frontmatter, body } = splitFrontmatter(raw)
|
||||
const lines = frontmatter ? frontmatter.split('\n') : []
|
||||
const data: Record<string, unknown> = {}
|
||||
let currentArrayKey = ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/)
|
||||
if (kv) {
|
||||
const key = kv[1]
|
||||
const value = kv[2]
|
||||
if (!value) {
|
||||
data[key] = []
|
||||
currentArrayKey = key
|
||||
} else {
|
||||
data[key] = value
|
||||
currentArrayKey = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
const arr = trimmed.match(/^- (.+)$/)
|
||||
if (arr && currentArrayKey) {
|
||||
const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : []
|
||||
next.push(arr[1].trim())
|
||||
data[currentArrayKey] = next
|
||||
}
|
||||
}
|
||||
|
||||
const id = normalizeText(data.id)
|
||||
const name = normalizeText(data.name, id || 'assistant')
|
||||
const applicableChatTypes = Array.isArray(data.applicableChatTypes)
|
||||
? (data.applicableChatTypes as string[]).filter((item): item is AssistantChatType => item === 'group' || item === 'private')
|
||||
: parseInlineList(String(data.applicableChatTypes || '')).filter((item): item is AssistantChatType => item === 'group' || item === 'private')
|
||||
const supportedLocales = Array.isArray(data.supportedLocales)
|
||||
? (data.supportedLocales as string[]).map((item) => item.trim()).filter(Boolean)
|
||||
: parseInlineList(String(data.supportedLocales || ''))
|
||||
const presetQuestions = Array.isArray(data.presetQuestions)
|
||||
? (data.presetQuestions as string[]).map((item) => item.trim()).filter(Boolean)
|
||||
: parseInlineList(String(data.presetQuestions || ''))
|
||||
const allowedBuiltinTools = Array.isArray(data.allowedBuiltinTools)
|
||||
? (data.allowedBuiltinTools as string[]).map((item) => item.trim()).filter(Boolean)
|
||||
: parseInlineList(String(data.allowedBuiltinTools || ''))
|
||||
const builtinId = normalizeText(data.builtinId)
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
systemPrompt: body,
|
||||
presetQuestions,
|
||||
allowedBuiltinTools,
|
||||
builtinId: builtinId || undefined,
|
||||
applicableChatTypes,
|
||||
supportedLocales
|
||||
}
|
||||
}
|
||||
|
||||
function toMarkdown(config: AssistantConfigFull): string {
|
||||
const lines = [
|
||||
'---',
|
||||
`id: ${config.id}`,
|
||||
`name: ${config.name}`
|
||||
]
|
||||
if (config.builtinId) lines.push(`builtinId: ${config.builtinId}`)
|
||||
if (config.supportedLocales && config.supportedLocales.length > 0) {
|
||||
lines.push('supportedLocales:')
|
||||
config.supportedLocales.forEach((item) => lines.push(` - ${item}`))
|
||||
}
|
||||
if (config.applicableChatTypes && config.applicableChatTypes.length > 0) {
|
||||
lines.push('applicableChatTypes:')
|
||||
config.applicableChatTypes.forEach((item) => lines.push(` - ${item}`))
|
||||
}
|
||||
if (config.presetQuestions && config.presetQuestions.length > 0) {
|
||||
lines.push('presetQuestions:')
|
||||
config.presetQuestions.forEach((item) => lines.push(` - ${item}`))
|
||||
}
|
||||
if (config.allowedBuiltinTools && config.allowedBuiltinTools.length > 0) {
|
||||
lines.push('allowedBuiltinTools:')
|
||||
config.allowedBuiltinTools.forEach((item) => lines.push(` - ${item}`))
|
||||
}
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
lines.push(config.systemPrompt || '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function defaultBuiltinToolCatalog(): Array<{ name: string; category: AssistantToolCategory }> {
|
||||
return [
|
||||
{ name: 'ai_query_time_window_activity', category: 'core' },
|
||||
{ name: 'ai_query_session_candidates', category: 'core' },
|
||||
{ name: 'ai_query_session_glimpse', category: 'core' },
|
||||
{ name: 'ai_query_timeline', category: 'core' },
|
||||
{ name: 'ai_fetch_message_briefs', category: 'core' },
|
||||
{ name: 'ai_list_voice_messages', category: 'core' },
|
||||
{ name: 'ai_transcribe_voice_messages', category: 'core' },
|
||||
{ name: 'ai_query_topic_stats', category: 'analysis' },
|
||||
{ name: 'ai_query_source_refs', category: 'analysis' },
|
||||
{ name: 'ai_query_top_contacts', category: 'analysis' },
|
||||
{ name: 'activate_skill', category: 'analysis' }
|
||||
]
|
||||
}
|
||||
|
||||
class AiAssistantService {
|
||||
private readonly config = ConfigService.getInstance()
|
||||
private initialized = false
|
||||
private readonly cache = new Map<string, AssistantConfigFull>()
|
||||
|
||||
private getRootDirCandidates(): string[] {
|
||||
const dbPath = normalizeText(this.config.get('dbPath'))
|
||||
const wxid = normalizeText(this.config.get('myWxid'))
|
||||
const roots: string[] = []
|
||||
if (dbPath && wxid) {
|
||||
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2'))
|
||||
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai'))
|
||||
}
|
||||
roots.push(join(process.cwd(), 'data', 'wf_ai_v2'))
|
||||
return roots
|
||||
}
|
||||
|
||||
private async getRootDir(): Promise<string> {
|
||||
const roots = this.getRootDirCandidates()
|
||||
const dir = roots[0]
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
private async getAssistantsDir(): Promise<string> {
|
||||
const root = await this.getRootDir()
|
||||
const dir = join(root, 'assistants')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
const dir = await this.getAssistantsDir()
|
||||
|
||||
for (const builtin of BUILTIN_ASSISTANTS) {
|
||||
const filePath = join(dir, `${builtin.id}.md`)
|
||||
if (!existsSync(filePath)) {
|
||||
const parsed = parseAssistantMarkdown(builtin.raw)
|
||||
const config: AssistantConfigFull = {
|
||||
...parsed,
|
||||
builtinId: parsed.id
|
||||
}
|
||||
await writeFile(filePath, toMarkdown(config), 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.clear()
|
||||
const files = await readdir(dir)
|
||||
for (const fileName of files) {
|
||||
if (!fileName.endsWith('.md')) continue
|
||||
const filePath = join(dir, fileName)
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
const parsed = parseAssistantMarkdown(raw)
|
||||
if (!parsed.id) continue
|
||||
this.cache.set(parsed.id, parsed)
|
||||
} catch {
|
||||
// ignore broken file
|
||||
}
|
||||
}
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async getAll(): Promise<AssistantSummary[]> {
|
||||
await this.ensureInitialized()
|
||||
return Array.from(this.cache.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
|
||||
.map((assistant) => ({ ...assistant }))
|
||||
}
|
||||
|
||||
async getConfig(id: string): Promise<AssistantConfigFull | null> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const config = this.cache.get(key)
|
||||
return config ? { ...config } : null
|
||||
}
|
||||
|
||||
async create(
|
||||
payload: Omit<AssistantConfigFull, 'id'> & { id?: string }
|
||||
): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const id = normalizeText(payload.id, `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`)
|
||||
if (this.cache.has(id)) return { success: false, error: '助手 ID 已存在' }
|
||||
const config: AssistantConfigFull = {
|
||||
id,
|
||||
name: normalizeText(payload.name, '新助手'),
|
||||
systemPrompt: normalizeText(payload.systemPrompt),
|
||||
presetQuestions: Array.isArray(payload.presetQuestions) ? payload.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : [],
|
||||
allowedBuiltinTools: Array.isArray(payload.allowedBuiltinTools) ? payload.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : [],
|
||||
builtinId: normalizeText(payload.builtinId) || undefined,
|
||||
applicableChatTypes: Array.isArray(payload.applicableChatTypes) ? payload.applicableChatTypes : [],
|
||||
supportedLocales: Array.isArray(payload.supportedLocales) ? payload.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : []
|
||||
}
|
||||
const dir = await this.getAssistantsDir()
|
||||
await writeFile(join(dir, `${id}.md`), toMarkdown(config), 'utf8')
|
||||
this.cache.set(id, config)
|
||||
return { success: true, id }
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updates: Partial<AssistantConfigFull>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const existing = this.cache.get(key)
|
||||
if (!existing) return { success: false, error: '助手不存在' }
|
||||
const next: AssistantConfigFull = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: key,
|
||||
name: normalizeText(updates.name, existing.name),
|
||||
systemPrompt: updates.systemPrompt == null ? existing.systemPrompt : normalizeText(updates.systemPrompt),
|
||||
presetQuestions: Array.isArray(updates.presetQuestions) ? updates.presetQuestions.map((item) => normalizeText(item)).filter(Boolean) : existing.presetQuestions,
|
||||
allowedBuiltinTools: Array.isArray(updates.allowedBuiltinTools) ? updates.allowedBuiltinTools.map((item) => normalizeText(item)).filter(Boolean) : existing.allowedBuiltinTools,
|
||||
applicableChatTypes: Array.isArray(updates.applicableChatTypes) ? updates.applicableChatTypes : existing.applicableChatTypes,
|
||||
supportedLocales: Array.isArray(updates.supportedLocales) ? updates.supportedLocales.map((item) => normalizeText(item)).filter(Boolean) : existing.supportedLocales
|
||||
}
|
||||
const dir = await this.getAssistantsDir()
|
||||
await writeFile(join(dir, `${key}.md`), toMarkdown(next), 'utf8')
|
||||
this.cache.set(key, next)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
if (key === 'general_cn' || key === 'general_en' || key === 'general_ja') {
|
||||
return { success: false, error: '默认助手不可删除' }
|
||||
}
|
||||
const dir = await this.getAssistantsDir()
|
||||
const filePath = join(dir, `${key}.md`)
|
||||
if (existsSync(filePath)) {
|
||||
await rm(filePath, { force: true })
|
||||
}
|
||||
this.cache.delete(key)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async reset(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const existing = this.cache.get(key)
|
||||
if (!existing?.builtinId) {
|
||||
return { success: false, error: '该助手不支持重置' }
|
||||
}
|
||||
const builtin = BUILTIN_ASSISTANTS.find((item) => item.id === existing.builtinId)
|
||||
if (!builtin) return { success: false, error: '内置模板不存在' }
|
||||
const parsed = parseAssistantMarkdown(builtin.raw)
|
||||
const config: AssistantConfigFull = {
|
||||
...parsed,
|
||||
id: key,
|
||||
builtinId: existing.builtinId
|
||||
}
|
||||
const dir = await this.getAssistantsDir()
|
||||
await writeFile(join(dir, `${key}.md`), toMarkdown(config), 'utf8')
|
||||
this.cache.set(key, config)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async getBuiltinCatalog(): Promise<BuiltinAssistantInfo[]> {
|
||||
await this.ensureInitialized()
|
||||
return BUILTIN_ASSISTANTS.map((builtin) => {
|
||||
const parsed = parseAssistantMarkdown(builtin.raw)
|
||||
const imported = Array.from(this.cache.values()).some((config) => config.builtinId === builtin.id || config.id === builtin.id)
|
||||
return {
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
systemPrompt: parsed.systemPrompt,
|
||||
applicableChatTypes: parsed.applicableChatTypes,
|
||||
supportedLocales: parsed.supportedLocales,
|
||||
imported
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getBuiltinToolCatalog(): Promise<Array<{ name: string; category: AssistantToolCategory }>> {
|
||||
return defaultBuiltinToolCatalog()
|
||||
}
|
||||
|
||||
async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
try {
|
||||
const parsed = parseAssistantMarkdown(rawMd)
|
||||
if (!parsed.id) return { success: false, error: '缺少 id' }
|
||||
if (this.cache.has(parsed.id)) return { success: false, error: '助手 ID 已存在' }
|
||||
const dir = await this.getAssistantsDir()
|
||||
await writeFile(join(dir, `${parsed.id}.md`), toMarkdown(parsed), 'utf8')
|
||||
this.cache.set(parsed.id, parsed)
|
||||
return { success: true, id: parsed.id }
|
||||
} catch (error) {
|
||||
return { success: false, error: String((error as Error)?.message || error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiAssistantService = new AiAssistantService()
|
||||
395
electron/services/aiSkillService.ts
Normal file
395
electron/services/aiSkillService.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export type SkillChatScope = 'all' | 'group' | 'private'
|
||||
|
||||
export interface SkillSummary {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
tags: string[]
|
||||
chatScope: SkillChatScope
|
||||
tools: string[]
|
||||
builtinId?: string
|
||||
}
|
||||
|
||||
export interface SkillDef extends SkillSummary {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface BuiltinSkillInfo extends SkillSummary {
|
||||
imported: boolean
|
||||
}
|
||||
|
||||
const SKILL_DEEP_TIMELINE_MD = `---
|
||||
id: deep_timeline
|
||||
name: 深度时间线追踪
|
||||
description: 适合还原某段时间内发生了什么,强调事件顺序与证据引用。
|
||||
tags:
|
||||
- timeline
|
||||
- evidence
|
||||
chatScope: all
|
||||
tools:
|
||||
- ai_query_time_window_activity
|
||||
- ai_query_session_candidates
|
||||
- ai_query_session_glimpse
|
||||
- ai_query_timeline
|
||||
- ai_fetch_message_briefs
|
||||
- ai_query_source_refs
|
||||
---
|
||||
你是“深度时间线追踪”技能。
|
||||
执行步骤:
|
||||
1. 先按时间窗扫描活跃会话,必要时补关键词筛选候选会话。
|
||||
2. 对候选会话先抽样,再拉取时间轴。
|
||||
3. 对关键节点用 ai_fetch_message_briefs 校对原文。
|
||||
4. 最后输出“结论 + 关键节点 + 来源范围”。`
|
||||
|
||||
const SKILL_CONTACT_FOCUS_MD = `---
|
||||
id: contact_focus
|
||||
name: 联系人关系聚焦
|
||||
description: 用于“我和谁聊得最多/关系变化”这类问题,强调联系人维度。
|
||||
tags:
|
||||
- contacts
|
||||
- relation
|
||||
chatScope: private
|
||||
tools:
|
||||
- ai_query_top_contacts
|
||||
- ai_query_topic_stats
|
||||
- ai_query_session_glimpse
|
||||
- ai_query_timeline
|
||||
- ai_query_source_refs
|
||||
---
|
||||
你是“联系人关系聚焦”技能。
|
||||
执行步骤:
|
||||
1. 优先调用 ai_query_top_contacts 得到候选联系人排名。
|
||||
2. 针对 Top 联系人读取抽样消息并补充时间轴。
|
||||
3. 如果用户问题涉及“变化趋势”,补 ai_query_topic_stats。
|
||||
4. 输出时必须给出对比口径(时间窗、样本范围、消息数量)。`
|
||||
|
||||
const SKILL_VOICE_AUDIT_MD = `---
|
||||
id: voice_audit
|
||||
name: 语音证据审计
|
||||
description: 对语音消息进行“先列ID再转写再总结”的合规分析。
|
||||
tags:
|
||||
- voice
|
||||
- audit
|
||||
chatScope: all
|
||||
tools:
|
||||
- ai_list_voice_messages
|
||||
- ai_transcribe_voice_messages
|
||||
- ai_query_source_refs
|
||||
---
|
||||
你是“语音证据审计”技能。
|
||||
硬规则:
|
||||
1. 必须先调用 ai_list_voice_messages 获取语音 ID 清单。
|
||||
2. 仅能转写用户明确指定的 ID,单轮最多 5 条。
|
||||
3. 未转写成功的语音不得作为事实。
|
||||
4. 输出包含“已转写 / 失败 / 待确认”三段。`
|
||||
|
||||
const BUILTIN_SKILLS = [
|
||||
{ id: 'deep_timeline', raw: SKILL_DEEP_TIMELINE_MD },
|
||||
{ id: 'contact_focus', raw: SKILL_CONTACT_FOCUS_MD },
|
||||
{ id: 'voice_audit', raw: SKILL_VOICE_AUDIT_MD }
|
||||
] as const
|
||||
|
||||
function normalizeText(value: unknown, fallback = ''): string {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || fallback
|
||||
}
|
||||
|
||||
function parseInlineList(text: string): string[] {
|
||||
const raw = normalizeText(text)
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function splitFrontmatter(raw: string): { frontmatter: string; body: string } {
|
||||
const normalized = String(raw || '')
|
||||
if (!normalized.startsWith('---')) {
|
||||
return { frontmatter: '', body: normalized.trim() }
|
||||
}
|
||||
const end = normalized.indexOf('\n---', 3)
|
||||
if (end < 0) return { frontmatter: '', body: normalized.trim() }
|
||||
return {
|
||||
frontmatter: normalized.slice(3, end).trim(),
|
||||
body: normalized.slice(end + 4).trim()
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChatScope(value: unknown): SkillChatScope {
|
||||
const scope = normalizeText(value).toLowerCase()
|
||||
if (scope === 'group' || scope === 'private') return scope
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function parseSkillMarkdown(raw: string): SkillDef {
|
||||
const { frontmatter, body } = splitFrontmatter(raw)
|
||||
const lines = frontmatter ? frontmatter.split('\n') : []
|
||||
const data: Record<string, unknown> = {}
|
||||
let currentArrayKey = ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
const kv = trimmed.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/)
|
||||
if (kv) {
|
||||
const key = kv[1]
|
||||
const value = kv[2]
|
||||
if (!value) {
|
||||
data[key] = []
|
||||
currentArrayKey = key
|
||||
} else {
|
||||
data[key] = value
|
||||
currentArrayKey = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
const arr = trimmed.match(/^- (.+)$/)
|
||||
if (arr && currentArrayKey) {
|
||||
const next = Array.isArray(data[currentArrayKey]) ? data[currentArrayKey] as string[] : []
|
||||
next.push(arr[1].trim())
|
||||
data[currentArrayKey] = next
|
||||
}
|
||||
}
|
||||
|
||||
const id = normalizeText(data.id)
|
||||
const name = normalizeText(data.name, id || 'skill')
|
||||
const description = normalizeText(data.description)
|
||||
const tags = Array.isArray(data.tags)
|
||||
? (data.tags as string[]).map((item) => item.trim()).filter(Boolean)
|
||||
: parseInlineList(String(data.tags || ''))
|
||||
const tools = Array.isArray(data.tools)
|
||||
? (data.tools as string[]).map((item) => item.trim()).filter(Boolean)
|
||||
: parseInlineList(String(data.tools || ''))
|
||||
const chatScope = normalizeChatScope(data.chatScope)
|
||||
const builtinId = normalizeText(data.builtinId)
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
chatScope,
|
||||
tools,
|
||||
prompt: body,
|
||||
builtinId: builtinId || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSkillMarkdown(skill: SkillDef): string {
|
||||
const lines = [
|
||||
'---',
|
||||
`id: ${skill.id}`,
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
`chatScope: ${skill.chatScope}`
|
||||
]
|
||||
if (skill.builtinId) lines.push(`builtinId: ${skill.builtinId}`)
|
||||
if (skill.tags.length > 0) {
|
||||
lines.push('tags:')
|
||||
skill.tags.forEach((tag) => lines.push(` - ${tag}`))
|
||||
}
|
||||
if (skill.tools.length > 0) {
|
||||
lines.push('tools:')
|
||||
skill.tools.forEach((tool) => lines.push(` - ${tool}`))
|
||||
}
|
||||
lines.push('---')
|
||||
lines.push('')
|
||||
lines.push(skill.prompt || '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
class AiSkillService {
|
||||
private readonly config = ConfigService.getInstance()
|
||||
private initialized = false
|
||||
private readonly cache = new Map<string, SkillDef>()
|
||||
|
||||
private getRootDirCandidates(): string[] {
|
||||
const dbPath = normalizeText(this.config.get('dbPath'))
|
||||
const wxid = normalizeText(this.config.get('myWxid'))
|
||||
const roots: string[] = []
|
||||
if (dbPath && wxid) {
|
||||
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai_v2'))
|
||||
roots.push(join(dbPath, wxid, 'db_storage', 'wf_ai'))
|
||||
}
|
||||
roots.push(join(process.cwd(), 'data', 'wf_ai_v2'))
|
||||
return roots
|
||||
}
|
||||
|
||||
private async getRootDir(): Promise<string> {
|
||||
const roots = this.getRootDirCandidates()
|
||||
const dir = roots[0]
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
private async getSkillsDir(): Promise<string> {
|
||||
const root = await this.getRootDir()
|
||||
const dir = join(root, 'skills')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) return
|
||||
const dir = await this.getSkillsDir()
|
||||
|
||||
for (const builtin of BUILTIN_SKILLS) {
|
||||
const filePath = join(dir, `${builtin.id}.md`)
|
||||
if (!existsSync(filePath)) {
|
||||
const parsed = parseSkillMarkdown(builtin.raw)
|
||||
const config: SkillDef = {
|
||||
...parsed,
|
||||
builtinId: parsed.id
|
||||
}
|
||||
await writeFile(filePath, serializeSkillMarkdown(config), 'utf8')
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
const parsed = parseSkillMarkdown(raw)
|
||||
if (!parsed.builtinId) {
|
||||
parsed.builtinId = builtin.id
|
||||
await writeFile(filePath, serializeSkillMarkdown(parsed), 'utf8')
|
||||
}
|
||||
} catch {
|
||||
// ignore broken file
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.clear()
|
||||
const files = await readdir(dir)
|
||||
for (const fileName of files) {
|
||||
if (!fileName.endsWith('.md')) continue
|
||||
const filePath = join(dir, fileName)
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
const parsed = parseSkillMarkdown(raw)
|
||||
if (!parsed.id) continue
|
||||
this.cache.set(parsed.id, parsed)
|
||||
} catch {
|
||||
// ignore broken file
|
||||
}
|
||||
}
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async getAll(): Promise<SkillSummary[]> {
|
||||
await this.ensureInitialized()
|
||||
return Array.from(this.cache.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
|
||||
.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
tags: [...skill.tags],
|
||||
chatScope: skill.chatScope,
|
||||
tools: [...skill.tools],
|
||||
builtinId: skill.builtinId
|
||||
}))
|
||||
}
|
||||
|
||||
async getConfig(id: string): Promise<SkillDef | null> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const value = this.cache.get(key)
|
||||
return value ? {
|
||||
...value,
|
||||
tags: [...value.tags],
|
||||
tools: [...value.tools]
|
||||
} : null
|
||||
}
|
||||
|
||||
async create(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const parsed = parseSkillMarkdown(rawMd)
|
||||
if (!parsed.id) return { success: false, error: '缺少 id' }
|
||||
if (this.cache.has(parsed.id)) return { success: false, error: '技能 ID 已存在' }
|
||||
const dir = await this.getSkillsDir()
|
||||
await writeFile(join(dir, `${parsed.id}.md`), serializeSkillMarkdown(parsed), 'utf8')
|
||||
this.cache.set(parsed.id, parsed)
|
||||
return { success: true, id: parsed.id }
|
||||
} catch (error) {
|
||||
return { success: false, error: String((error as Error)?.message || error) }
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, rawMd: string): Promise<{ success: boolean; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const existing = this.cache.get(key)
|
||||
if (!existing) return { success: false, error: '技能不存在' }
|
||||
try {
|
||||
const parsed = parseSkillMarkdown(rawMd)
|
||||
parsed.id = key
|
||||
if (existing.builtinId && !parsed.builtinId) parsed.builtinId = existing.builtinId
|
||||
const dir = await this.getSkillsDir()
|
||||
await writeFile(join(dir, `${key}.md`), serializeSkillMarkdown(parsed), 'utf8')
|
||||
this.cache.set(key, parsed)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: String((error as Error)?.message || error) }
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
await this.ensureInitialized()
|
||||
const key = normalizeText(id)
|
||||
const dir = await this.getSkillsDir()
|
||||
const filePath = join(dir, `${key}.md`)
|
||||
if (existsSync(filePath)) {
|
||||
await rm(filePath, { force: true })
|
||||
}
|
||||
this.cache.delete(key)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async getBuiltinCatalog(): Promise<BuiltinSkillInfo[]> {
|
||||
await this.ensureInitialized()
|
||||
return BUILTIN_SKILLS.map((builtin) => {
|
||||
const parsed = parseSkillMarkdown(builtin.raw)
|
||||
const imported = Array.from(this.cache.values()).some((skill) => skill.builtinId === parsed.id || skill.id === parsed.id)
|
||||
return {
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
tags: parsed.tags,
|
||||
chatScope: parsed.chatScope,
|
||||
tools: parsed.tools,
|
||||
imported
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async importFromMd(rawMd: string): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||
return this.create(rawMd)
|
||||
}
|
||||
|
||||
async getAutoSkillMenu(
|
||||
chatScope: SkillChatScope,
|
||||
allowedTools?: string[]
|
||||
): Promise<string | null> {
|
||||
await this.ensureInitialized()
|
||||
const compatible = Array.from(this.cache.values()).filter((skill) => {
|
||||
if (skill.chatScope !== 'all' && skill.chatScope !== chatScope) return false
|
||||
if (!allowedTools || allowedTools.length === 0) return true
|
||||
return skill.tools.every((tool) => allowedTools.includes(tool))
|
||||
})
|
||||
if (compatible.length === 0) return null
|
||||
const lines = compatible.slice(0, 15).map((skill) => `- ${skill.id}: ${skill.name} - ${skill.description}`)
|
||||
return [
|
||||
'你可以按需调用工具 activate_skill 以激活对应技能。',
|
||||
'当用户问题明显匹配某个技能时,先调用 activate_skill 获取执行手册。',
|
||||
'若问题简单或不匹配技能,可直接回答。',
|
||||
'',
|
||||
...lines
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
export const aiSkillService = new AiSkillService()
|
||||
@@ -1135,7 +1135,7 @@ class AnnualReportService {
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastProgressAt > 200) {
|
||||
let progress = 30
|
||||
let progress: number
|
||||
if (totalMessagesForProgress > 0) {
|
||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||
progress = 30 + Math.floor(ratio * 50)
|
||||
|
||||
219
electron/services/avatarFileCacheService.ts
Normal file
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "./config";
|
||||
|
||||
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||
export class AvatarFileCacheService {
|
||||
private static instance: AvatarFileCacheService | null = null;
|
||||
|
||||
// 头像文件缓存目录
|
||||
private readonly cacheDir: string;
|
||||
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||
new Map();
|
||||
// LRU 追踪:文件路径->最后访问时间
|
||||
private readonly lruOrder: string[] = [];
|
||||
private readonly maxCacheFiles = 100;
|
||||
|
||||
private constructor() {
|
||||
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||
this.cacheDir = join(basePath, "avatar-files");
|
||||
this.ensureCacheDir();
|
||||
this.loadLruOrder();
|
||||
}
|
||||
|
||||
public static getInstance(): AvatarFileCacheService {
|
||||
if (!AvatarFileCacheService.instance) {
|
||||
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||
}
|
||||
return AvatarFileCacheService.instance;
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
// 同步确保目录存在(构造函数调用)
|
||||
try {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async ensureCacheDirAsync(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getFilePath(url: string): string {
|
||||
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||
const hash = this.hashString(url);
|
||||
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
private async loadLruOrder(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
// 按修改时间排序(旧的在前)
|
||||
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||
this.lruOrder.length = 0;
|
||||
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private updateLru(fileName: string): void {
|
||||
const index = this.lruOrder.indexOf(fileName);
|
||||
if (index > -1) {
|
||||
this.lruOrder.splice(index, 1);
|
||||
}
|
||||
this.lruOrder.push(fileName);
|
||||
}
|
||||
|
||||
private async evictIfNeeded(): Promise<void> {
|
||||
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||
const oldest = this.lruOrder.shift();
|
||||
if (oldest) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, oldest));
|
||||
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAvatar(url: string): Promise<string | null> {
|
||||
const localPath = this.getFilePath(url);
|
||||
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.access(localPath);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
return localPath;
|
||||
} catch {}
|
||||
|
||||
await this.ensureCacheDirAsync();
|
||||
await this.evictIfNeeded();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const options = {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
Referer: "https://servicewechat.com/",
|
||||
Accept:
|
||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
};
|
||||
|
||||
const callback = (res: IncomingMessage) => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.writeFile(localPath, buffer);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
console.log(
|
||||
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||
);
|
||||
resolve(localPath);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", () => resolve(null));
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取头像本地文件路径,如果需要会下载
|
||||
* 同一URL并发调用会复用同一个下载任务
|
||||
*/
|
||||
async getAvatarPath(url: string): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
// 检查是否有正在进行的下载
|
||||
const pending = this.pendingDownloads.get(url);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// 发起新下载
|
||||
const downloadPromise = this.downloadAvatar(url);
|
||||
this.pendingDownloads.set(url, downloadPromise);
|
||||
|
||||
try {
|
||||
const result = await downloadPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingDownloads.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有缓存文件(App退出时调用)
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, entry));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.lruOrder.length = 0;
|
||||
console.log("[AvatarFileCache] Cache cleared");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 获取当前缓存的文件数量
|
||||
async getCacheCount(): Promise<number> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
return entries.filter(
|
||||
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||
).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||
243
electron/services/bizService.ts
Normal file
243
electron/services/bizService.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { join } from 'path'
|
||||
import { readdirSync, existsSync } from 'fs'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { ipcMain } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface BizAccount {
|
||||
username: string
|
||||
name: string
|
||||
avatar: string
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
des: string
|
||||
url: string
|
||||
cover: string
|
||||
content_list: any[]
|
||||
}
|
||||
|
||||
export interface BizPayRecord {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
description: string
|
||||
merchant_name: string
|
||||
merchant_icon: string
|
||||
timestamp: number
|
||||
formatted_time: string
|
||||
}
|
||||
|
||||
export class BizService {
|
||||
private configService: ConfigService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private extractXmlValue(xml: string, tagName: string): string {
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||
const match = regex.exec(xml)
|
||||
if (match) {
|
||||
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseBizContentList(xmlStr: string): any[] {
|
||||
if (!xmlStr) return []
|
||||
const contentList: any[] = []
|
||||
try {
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||
const itemXml = match[1]
|
||||
const itemStruct = {
|
||||
title: this.extractXmlValue(itemXml, 'title'),
|
||||
url: this.extractXmlValue(itemXml, 'url'),
|
||||
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||
}
|
||||
if (itemStruct.title) contentList.push(itemStruct)
|
||||
}
|
||||
} catch (e) { }
|
||||
return contentList
|
||||
}
|
||||
|
||||
private parsePayXml(xmlStr: string): any {
|
||||
if (!xmlStr) return null
|
||||
try {
|
||||
const title = this.extractXmlValue(xmlStr, 'title')
|
||||
const description = this.extractXmlValue(xmlStr, 'des')
|
||||
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||
if (!title && !description) return null
|
||||
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||
} catch (e) { return null }
|
||||
}
|
||||
|
||||
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||
try {
|
||||
// 1. 获取公众号联系人列表
|
||||
const contactsResult = await chatService.getContacts({ lite: true })
|
||||
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||
|
||||
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||
const usernames = officialContacts.map(c => c.username)
|
||||
|
||||
// 获取头像和昵称等补充信息
|
||||
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||
|
||||
const root = this.configService.get('dbPath')
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const accountWxid = account || myWxid
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await wcdbService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||
// 适配日志中发现的字段,注意转为整型数字
|
||||
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const time = parseInt(timeStr.toString(), 10)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
bizLatestTime[uname] = time
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取 Sessions 失败:', e)
|
||||
}
|
||||
|
||||
// 3. 格式化时间显示
|
||||
const formatBizTime = (ts: number) => {
|
||||
if (!ts) return ''
|
||||
const date = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
|
||||
const isThisYear = date.getFullYear() === now.getFullYear()
|
||||
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
|
||||
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 4. 组装数据
|
||||
const result: BizAccount[] = officialContacts.map(contact => {
|
||||
const uname = contact.username
|
||||
const info = contactInfoMap[uname]
|
||||
const lastTime = bizLatestTime[uname] || 0
|
||||
return {
|
||||
username: uname,
|
||||
name: info?.displayName || contact.displayName || uname,
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime)
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 补充公众号类型 (订阅号/服务号)
|
||||
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
|
||||
if (existsSync(contactDbPath)) {
|
||||
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||
const typeMap: Record<string, number> = {}
|
||||
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 排序输出
|
||||
return result
|
||||
.filter(acc => !acc.name.includes('广告'))
|
||||
.sort((a, b) => {
|
||||
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
|
||||
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||
return b.last_time - a.last_time // 按最新时间降序排列
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('获取账号列表发生错误:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||
try {
|
||||
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
return res.messages.map(msg => {
|
||||
const bizMsg: BizMessage = {
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
title: msg.linkTitle || msg.parsedContent || '',
|
||||
des: msg.appMsgDesc || '',
|
||||
url: msg.linkUrl || '',
|
||||
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||
content_list: []
|
||||
}
|
||||
if (msg.rawContent) {
|
||||
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||
bizMsg.title = bizMsg.content_list[0].title
|
||||
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||
}
|
||||
}
|
||||
return bizMsg
|
||||
})
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||
const username = 'gh_3dfda90e39d6'
|
||||
try {
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
const records: BizPayRecord[] = []
|
||||
for (const msg of res.messages) {
|
||||
if (!msg.rawContent) continue
|
||||
const parsedData = this.parsePayXml(msg.rawContent)
|
||||
if (parsedData) {
|
||||
records.push({
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
...parsedData,
|
||||
timestamp: parsedData.timestamp || msg.createTime,
|
||||
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return records
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
registerHandlers() {
|
||||
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||
}
|
||||
}
|
||||
|
||||
export const bizService = new BizService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,31 @@ class CloudControlService {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
private platformVersionCache: string | null = null
|
||||
private pendingReports: UsageStats[] = []
|
||||
private flushInProgress = false
|
||||
private retryDelayMs = 5_000
|
||||
private consecutiveFailures = 0
|
||||
private circuitOpenedAt = 0
|
||||
private nextDelayOverrideMs: number | null = null
|
||||
private initialized = false
|
||||
|
||||
private static readonly BASE_FLUSH_MS = 300_000
|
||||
private static readonly JITTER_MS = 30_000
|
||||
private static readonly MAX_BUFFER_REPORTS = 200
|
||||
private static readonly MAX_BATCH_REPORTS = 20
|
||||
private static readonly MAX_RETRY_MS = 120_000
|
||||
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
|
||||
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
this.deviceId = this.getDeviceId()
|
||||
await wcdbService.cloudInit(300)
|
||||
await this.reportOnline()
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.reportOnline()
|
||||
}, 300000)
|
||||
this.enqueueCurrentReport()
|
||||
await this.flushQueue(true)
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
}
|
||||
|
||||
private getDeviceId(): string {
|
||||
@@ -33,8 +49,8 @@ class CloudControlService {
|
||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||
}
|
||||
|
||||
private async reportOnline() {
|
||||
const data: UsageStats = {
|
||||
private buildCurrentReport(): UsageStats {
|
||||
return {
|
||||
appVersion: app.getVersion(),
|
||||
platform: this.getPlatformVersion(),
|
||||
deviceId: this.deviceId,
|
||||
@@ -42,11 +58,69 @@ class CloudControlService {
|
||||
online: true,
|
||||
pages: Array.from(this.pages)
|
||||
}
|
||||
}
|
||||
|
||||
await wcdbService.cloudReport(JSON.stringify(data))
|
||||
private enqueueCurrentReport() {
|
||||
const report = this.buildCurrentReport()
|
||||
this.pendingReports.push(report)
|
||||
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
|
||||
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
|
||||
}
|
||||
this.pages.clear()
|
||||
}
|
||||
|
||||
private isCircuitOpen(nowMs: number): boolean {
|
||||
if (this.circuitOpenedAt <= 0) return false
|
||||
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
|
||||
}
|
||||
|
||||
private scheduleNextFlush(delayMs?: number) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
|
||||
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
|
||||
this.timer = setTimeout(() => {
|
||||
this.enqueueCurrentReport()
|
||||
this.flushQueue(false).finally(() => {
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
})
|
||||
}, nextDelay)
|
||||
}
|
||||
|
||||
private async flushQueue(force: boolean) {
|
||||
if (this.flushInProgress) return
|
||||
if (this.pendingReports.length === 0) return
|
||||
const now = Date.now()
|
||||
if (!force && this.isCircuitOpen(now)) {
|
||||
return
|
||||
}
|
||||
this.flushInProgress = true
|
||||
try {
|
||||
while (this.pendingReports.length > 0) {
|
||||
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
|
||||
const result = await wcdbService.cloudReport(JSON.stringify(batch))
|
||||
if (!result || result.success !== true) {
|
||||
this.consecutiveFailures += 1
|
||||
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
|
||||
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
|
||||
this.circuitOpenedAt = Date.now()
|
||||
}
|
||||
this.nextDelayOverrideMs = this.retryDelayMs
|
||||
return
|
||||
}
|
||||
this.pendingReports.splice(0, batch.length)
|
||||
this.consecutiveFailures = 0
|
||||
this.retryDelayMs = 5_000
|
||||
this.circuitOpenedAt = 0
|
||||
}
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
if (this.platformVersionCache) {
|
||||
return this.platformVersionCache
|
||||
@@ -146,9 +220,16 @@ class CloudControlService {
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.pendingReports = []
|
||||
this.flushInProgress = false
|
||||
this.retryDelayMs = 5_000
|
||||
this.consecutiveFailures = 0
|
||||
this.circuitOpenedAt = 0
|
||||
this.nextDelayOverrideMs = null
|
||||
this.initialized = false
|
||||
wcdbService.cloudStop()
|
||||
}
|
||||
|
||||
@@ -158,4 +239,3 @@ class CloudControlService {
|
||||
}
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import Store from 'electron-store'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const isSafeStorageAvailable = (): boolean => {
|
||||
try {
|
||||
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
@@ -27,6 +34,7 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -45,6 +53,7 @@ interface ConfigSchema {
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
@@ -59,10 +68,59 @@ interface ConfigSchema {
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
exportWriteLayout: 'A' | 'B' | 'C'
|
||||
|
||||
// AI 见解
|
||||
aiModelApiBaseUrl: string
|
||||
aiModelApiKey: string
|
||||
aiModelApiModel: string
|
||||
aiAgentMaxMessagesPerRequest: number
|
||||
aiAgentMaxHistoryRounds: number
|
||||
aiAgentEnableAutoSkill: boolean
|
||||
aiAgentSearchContextBefore: number
|
||||
aiAgentSearchContextAfter: number
|
||||
aiAgentPreprocessClean: boolean
|
||||
aiAgentPreprocessMerge: boolean
|
||||
aiAgentPreprocessDenoise: boolean
|
||||
aiAgentPreprocessDesensitize: boolean
|
||||
aiAgentPreprocessAnonymize: boolean
|
||||
aiInsightEnabled: boolean
|
||||
aiInsightApiBaseUrl: string
|
||||
aiInsightApiKey: string
|
||||
aiInsightApiModel: string
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
aiInsightCooldownMinutes: number
|
||||
/** 沉默联系人扫描间隔(小时) */
|
||||
aiInsightScanIntervalHours: number
|
||||
/** 发送上下文时的最大消息条数 */
|
||||
aiInsightContextCount: number
|
||||
/** 自定义 system prompt,空字符串表示使用内置默认值 */
|
||||
aiInsightSystemPrompt: string
|
||||
/** 是否启用 Telegram 推送 */
|
||||
aiInsightTelegramEnabled: boolean
|
||||
/** Telegram Bot Token */
|
||||
aiInsightTelegramToken: string
|
||||
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
|
||||
aiInsightTelegramChatIds: string
|
||||
|
||||
// AI 足迹
|
||||
aiFootprintEnabled: boolean
|
||||
aiFootprintSystemPrompt: string
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||
'decryptKey',
|
||||
'imageAesKey',
|
||||
'authPassword',
|
||||
'httpApiToken',
|
||||
'aiModelApiKey',
|
||||
'aiInsightApiKey'
|
||||
])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
@@ -119,6 +177,7 @@ export class ConfigService {
|
||||
authUseHello: false,
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
updateChannel: 'auto',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
@@ -130,7 +189,38 @@ export class ConfigService {
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: []
|
||||
wordCloudExcludeWords: [],
|
||||
exportWriteLayout: 'A',
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
aiModelApiModel: 'gpt-4o-mini',
|
||||
aiAgentMaxMessagesPerRequest: 120,
|
||||
aiAgentMaxHistoryRounds: 12,
|
||||
aiAgentEnableAutoSkill: true,
|
||||
aiAgentSearchContextBefore: 3,
|
||||
aiAgentSearchContextAfter: 3,
|
||||
aiAgentPreprocessClean: true,
|
||||
aiAgentPreprocessMerge: true,
|
||||
aiAgentPreprocessDenoise: true,
|
||||
aiAgentPreprocessDesensitize: false,
|
||||
aiAgentPreprocessAnonymize: false,
|
||||
aiInsightEnabled: false,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightWhitelistEnabled: false,
|
||||
aiInsightWhitelist: [],
|
||||
aiInsightCooldownMinutes: 120,
|
||||
aiInsightScanIntervalHours: 4,
|
||||
aiInsightContextCount: 40,
|
||||
aiInsightSystemPrompt: '',
|
||||
aiInsightTelegramEnabled: false,
|
||||
aiInsightTelegramToken: '',
|
||||
aiInsightTelegramChatIds: '',
|
||||
aiFootprintEnabled: false,
|
||||
aiFootprintSystemPrompt: ''
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
@@ -162,6 +252,7 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
this.migrateAuthFields()
|
||||
this.migrateAiConfig()
|
||||
}
|
||||
|
||||
// === 状态查询 ===
|
||||
@@ -219,7 +310,9 @@ export class ConfigService {
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
@@ -252,7 +345,7 @@ export class ConfigService {
|
||||
private safeEncrypt(plaintext: string): string {
|
||||
if (!plaintext) return ''
|
||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||
if (!isSafeStorageAvailable()) return plaintext
|
||||
const encrypted = safeStorage.encryptString(plaintext)
|
||||
return SAFE_PREFIX + encrypted.toString('base64')
|
||||
}
|
||||
@@ -260,7 +353,7 @@ export class ConfigService {
|
||||
private safeDecrypt(stored: string): string {
|
||||
if (!stored) return ''
|
||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||
if (!isSafeStorageAvailable()) return ''
|
||||
try {
|
||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||
return safeStorage.decryptString(buf)
|
||||
@@ -598,7 +691,7 @@ export class ConfigService {
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
@@ -607,13 +700,18 @@ export class ConfigService {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
if (rawEnabled === true || rawEnabled === 'true') {
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||
this.store.set('authEnabled', false as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
if (rawUseHello === true || rawUseHello === 'true') {
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
@@ -659,6 +757,26 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAiConfig(): void {
|
||||
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
|
||||
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
|
||||
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
|
||||
|
||||
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
|
||||
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
|
||||
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
|
||||
|
||||
if (!sharedBaseUrl && legacyBaseUrl) {
|
||||
this.set('aiModelApiBaseUrl', legacyBaseUrl)
|
||||
}
|
||||
if (!sharedApiKey && legacyApiKey) {
|
||||
this.set('aiModelApiKey', legacyApiKey)
|
||||
}
|
||||
if (!sharedModel && legacyModel) {
|
||||
this.set('aiModelApiModel', legacyModel)
|
||||
}
|
||||
}
|
||||
|
||||
// === 验证 ===
|
||||
|
||||
verifyAuthEnabled(): boolean {
|
||||
|
||||
@@ -19,7 +19,8 @@ class ExportRecordService {
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app.getPath('userData')
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||
return this.filePath
|
||||
|
||||
@@ -92,12 +92,15 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
exportVoices?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportFiles?: boolean
|
||||
maxFileSizeMb?: number
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
@@ -121,7 +124,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||
|
||||
interface MediaExportItem {
|
||||
relativePath: string
|
||||
kind: 'image' | 'voice' | 'emoji' | 'video'
|
||||
kind: 'image' | 'voice' | 'emoji' | 'video' | 'file'
|
||||
posterDataUrl?: string
|
||||
}
|
||||
|
||||
@@ -136,6 +139,11 @@ interface ExportDisplayProfile {
|
||||
|
||||
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
||||
interface FileExportCandidate {
|
||||
sourcePath: string
|
||||
matchedBy: 'md5' | 'name'
|
||||
yearMonth?: string
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
current: number
|
||||
@@ -247,6 +255,7 @@ async function parallelLimit<T, R>(
|
||||
|
||||
class ExportService {
|
||||
private configService: ConfigService
|
||||
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
|
||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||
private inlineEmojiCache: LRUCache<string, string>
|
||||
private htmlStyleCache: string | null = null
|
||||
@@ -288,6 +297,10 @@ class ExportService {
|
||||
return error
|
||||
}
|
||||
|
||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||
this.runtimeConfig = config
|
||||
}
|
||||
|
||||
private normalizeSessionIds(sessionIds: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
|
||||
@@ -430,6 +443,8 @@ class ExportService {
|
||||
let lastSessionId = ''
|
||||
let lastCollected = 0
|
||||
let lastExported = 0
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
||||
|
||||
const commit = (progress: ExportProgress) => {
|
||||
onProgress(progress)
|
||||
@@ -454,9 +469,9 @@ class ExportService {
|
||||
const shouldEmit = force ||
|
||||
phase !== lastPhase ||
|
||||
sessionId !== lastSessionId ||
|
||||
collectedDelta >= 200 ||
|
||||
exportedDelta >= 200 ||
|
||||
(now - lastSentAt >= 120)
|
||||
collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)
|
||||
|
||||
if (shouldEmit && pending) {
|
||||
commit(pending)
|
||||
@@ -480,6 +495,80 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeExportFileNamePart(value: string): string {
|
||||
return String(value || '')
|
||||
.replace(/[<>:"\/\\|?*]/g, '_')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||
}
|
||||
|
||||
private formatDateTokenBySeconds(seconds?: number): string | null {
|
||||
if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null
|
||||
const date = new Date(Math.floor(Number(seconds)) * 1000)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
|
||||
private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
|
||||
const start = this.formatDateTokenBySeconds(dateRange?.start)
|
||||
const end = this.formatDateTokenBySeconds(dateRange?.end)
|
||||
if (start && end) {
|
||||
if (start === end) return start
|
||||
return start < end ? `${start}-${end}` : `${end}-${start}`
|
||||
}
|
||||
if (start) return `${start}-至今`
|
||||
if (end) return `截至-${end}`
|
||||
return '全部时间'
|
||||
}
|
||||
|
||||
private buildSessionExportBaseName(
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
options: ExportOptions
|
||||
): string {
|
||||
const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
|
||||
const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
|
||||
const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
|
||||
const parts = [baseName]
|
||||
if (suffix) parts.push(suffix)
|
||||
if (namingMode === 'date-range') {
|
||||
parts.push(this.buildDateRangeFileNamePart(options.dateRange))
|
||||
}
|
||||
return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
|
||||
}
|
||||
|
||||
private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
|
||||
const dir = path.dirname(preferredPath)
|
||||
const ext = path.extname(preferredPath)
|
||||
const base = path.basename(preferredPath, ext)
|
||||
|
||||
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
||||
const candidate = attempt === 0
|
||||
? preferredPath
|
||||
: path.join(dir, `${base}_${attempt + 1}${ext}`)
|
||||
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
|
||||
const exists = await this.pathExists(candidate)
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
if (exists) continue
|
||||
|
||||
reservedPaths.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
|
||||
reservedPaths.add(fallback)
|
||||
return fallback
|
||||
}
|
||||
|
||||
private isCloneUnsupportedError(code: string | undefined): boolean {
|
||||
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
||||
}
|
||||
@@ -842,7 +931,7 @@ class ExportService {
|
||||
|
||||
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||
return options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||
}
|
||||
|
||||
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
|
||||
@@ -880,7 +969,7 @@ class ExportService {
|
||||
if (options.exportImages) selected.add(3)
|
||||
if (options.exportVoices) selected.add(34)
|
||||
if (options.exportVideos) selected.add(43)
|
||||
if (options.exportEmojis) selected.add(47)
|
||||
if (options.exportFiles) selected.add(49)
|
||||
return selected
|
||||
}
|
||||
|
||||
@@ -1307,9 +1396,9 @@ class ExportService {
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const decryptKey = this.configService.get('decryptKey')
|
||||
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
||||
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
||||
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||
@@ -1414,7 +1503,7 @@ class ExportService {
|
||||
}
|
||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
console.error('getGroupNicknamesForRoom service error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
@@ -2245,7 +2334,7 @@ class ExportService {
|
||||
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
||||
const quoteInfo = this.parseQuoteMessage(normalized)
|
||||
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
|
||||
const quotedPreview = this.formatQuotedReferencePreview(
|
||||
const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
|
||||
this.extractXmlValue(referMsgXml, 'content'),
|
||||
this.extractXmlValue(referMsgXml, 'type')
|
||||
)
|
||||
@@ -2951,7 +3040,7 @@ class ExportService {
|
||||
|
||||
switch (referType) {
|
||||
case '1':
|
||||
displayContent = this.sanitizeQuotedContent(referContent)
|
||||
displayContent = this.extractPreferredQuotedText(referMsgXml)
|
||||
break
|
||||
case '3':
|
||||
displayContent = '[图片]'
|
||||
@@ -2992,6 +3081,76 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private extractPreferredQuotedText(referMsgXml: string): string {
|
||||
if (!referMsgXml) return ''
|
||||
|
||||
const sources = [this.decodeHtmlEntities(referMsgXml)]
|
||||
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
|
||||
if (rawMsgSource) {
|
||||
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
|
||||
if (decodedMsgSource) {
|
||||
sources.push(decodedMsgSource)
|
||||
}
|
||||
}
|
||||
|
||||
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
|
||||
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
|
||||
if (partialText) return partialText
|
||||
|
||||
const candidateTags = [
|
||||
'selectedcontent',
|
||||
'selectedtext',
|
||||
'selectcontent',
|
||||
'selecttext',
|
||||
'quotecontent',
|
||||
'quotetext',
|
||||
'partcontent',
|
||||
'parttext',
|
||||
'excerpt',
|
||||
'summary',
|
||||
'preview'
|
||||
]
|
||||
|
||||
for (const source of sources) {
|
||||
for (const tag of candidateTags) {
|
||||
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
|
||||
if (value) return value
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent
|
||||
}
|
||||
|
||||
private extractPartialQuotedText(xml: string, fullContent: string): string {
|
||||
if (!xml || !fullContent) return ''
|
||||
|
||||
const startChar = this.extractXmlValue(xml, 'start')
|
||||
const endChar = this.extractXmlValue(xml, 'end')
|
||||
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
|
||||
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
|
||||
const startIndex = Number.parseInt(startIndexRaw, 10)
|
||||
const endIndex = Number.parseInt(endIndexRaw, 10)
|
||||
|
||||
if (startChar && endChar) {
|
||||
const startPos = fullContent.indexOf(startChar)
|
||||
if (startPos !== -1) {
|
||||
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
|
||||
if (endPos !== -1 && endPos >= startPos) {
|
||||
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
|
||||
if (sliced) return sliced
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
|
||||
const chars = Array.from(fullContent)
|
||||
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
|
||||
if (sliced) return sliced
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private extractChatLabReplyToMessageId(content: string): string | undefined {
|
||||
try {
|
||||
const normalized = this.normalizeAppMessageContent(content || '')
|
||||
@@ -3310,15 +3469,29 @@ class ExportService {
|
||||
const subType = this.extractAppMessageType(normalized)
|
||||
if (subType && subType !== '5' && subType !== '49') return null
|
||||
|
||||
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
|
||||
const url = [
|
||||
this.extractXmlValue(normalized, 'url'),
|
||||
this.extractXmlValue(normalized, 'shareurlopen'),
|
||||
this.extractXmlValue(normalized, 'shareurloriginal'),
|
||||
this.extractXmlValue(normalized, 'shareurl'),
|
||||
this.extractXmlValue(normalized, 'shorturl'),
|
||||
this.extractXmlValue(normalized, 'dataurl'),
|
||||
this.extractXmlValue(normalized, 'lowurl'),
|
||||
this.extractXmlValue(normalized, 'streamvideoweburl'),
|
||||
this.extractXmlValue(normalized, 'weburl')
|
||||
]
|
||||
.map(candidate => this.normalizeHtmlLinkUrl(candidate))
|
||||
.find(Boolean) || ''
|
||||
if (!url) return null
|
||||
|
||||
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
||||
const title = this.stripSenderPrefix(
|
||||
this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
||||
) || url
|
||||
return { title, url }
|
||||
}
|
||||
|
||||
private normalizeHtmlLinkUrl(rawUrl: string): string {
|
||||
const value = (rawUrl || '').trim()
|
||||
const value = (rawUrl || '').trim().replace(/&/gi, '&')
|
||||
if (!value) return ''
|
||||
|
||||
const parseHttpUrl = (candidate: string): string => {
|
||||
@@ -3349,6 +3522,46 @@ class ExportService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string {
|
||||
const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim())
|
||||
return normalizedTitle || linkCard.url || '链接'
|
||||
}
|
||||
|
||||
private formatLinkCardExportText(
|
||||
content: string,
|
||||
localType: number,
|
||||
style: 'markdown' | 'append-url'
|
||||
): string | null {
|
||||
const linkCard = this.extractHtmlLinkCard(content, localType)
|
||||
if (!linkCard?.url) return null
|
||||
|
||||
const title = this.getLinkCardDisplayTitle(linkCard)
|
||||
if (style === 'markdown') {
|
||||
return `[${title}](${linkCard.url})`
|
||||
}
|
||||
|
||||
const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]'
|
||||
return `${prefix}\n${linkCard.url}`
|
||||
}
|
||||
|
||||
private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean {
|
||||
const linkCard = this.extractHtmlLinkCard(content, localType)
|
||||
if (!linkCard?.url) return false
|
||||
|
||||
const title = this.getLinkCardDisplayTitle(linkCard)
|
||||
cell.value = {
|
||||
text: title,
|
||||
hyperlink: linkCard.url,
|
||||
tooltip: linkCard.url
|
||||
} as any
|
||||
cell.font = {
|
||||
...(cell.font || {}),
|
||||
color: { argb: 'FF0563C1' },
|
||||
underline: true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出媒体文件到指定目录
|
||||
*/
|
||||
@@ -3362,6 +3575,8 @@ class ExportService {
|
||||
exportVoices?: boolean
|
||||
exportVideos?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportFiles?: boolean
|
||||
maxFileSizeMb?: number
|
||||
exportVoiceAsText?: boolean
|
||||
includeVideoPoster?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
@@ -3415,6 +3630,16 @@ class ExportService {
|
||||
)
|
||||
}
|
||||
|
||||
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') {
|
||||
return this.exportFileAttachment(
|
||||
msg,
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.maxFileSizeMb,
|
||||
options.dirCache
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3483,20 +3708,11 @@ class ExportService {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
result.localPath = thumbResult.localPath
|
||||
} else {
|
||||
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
|
||||
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
|
||||
const { imageStore } = await import('../main')
|
||||
const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName)
|
||||
if (cachedThumb) {
|
||||
console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`)
|
||||
result.localPath = cachedThumb
|
||||
} else {
|
||||
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
|
||||
if (missingRunCacheKey) {
|
||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||
}
|
||||
return null
|
||||
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`)
|
||||
if (missingRunCacheKey) {
|
||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3505,7 +3721,7 @@ class ExportService {
|
||||
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
|
||||
// 从 data URL 或 file URL 获取实际路径
|
||||
let sourcePath = result.localPath
|
||||
let sourcePath: string = result.localPath!
|
||||
if (sourcePath.startsWith('data:')) {
|
||||
// 是 data URL,需要保存为文件
|
||||
const base64Data = sourcePath.split(',')[1]
|
||||
@@ -3885,6 +4101,165 @@ class ExportService {
|
||||
return tagMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
private resolveFileAttachmentRoots(): string[] {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||
if (!dbPath) return []
|
||||
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const roots = new Set<string>()
|
||||
const tryAddRoot = (candidate: string) => {
|
||||
const fileRoot = path.join(candidate, 'msg', 'file')
|
||||
if (fs.existsSync(fileRoot)) {
|
||||
roots.add(fileRoot)
|
||||
}
|
||||
}
|
||||
|
||||
tryAddRoot(normalized)
|
||||
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid))
|
||||
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
|
||||
|
||||
const dbStoragePath =
|
||||
this.resolveDbStoragePathForExport(normalized, cleanedWxid) ||
|
||||
this.resolveDbStoragePathForExport(normalized, rawWxid)
|
||||
if (dbStoragePath) {
|
||||
tryAddRoot(path.dirname(dbStoragePath))
|
||||
}
|
||||
|
||||
return Array.from(roots)
|
||||
}
|
||||
|
||||
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
|
||||
const raw = Number(createTime)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return []
|
||||
const ts = raw > 1e12 ? raw : raw * 1000
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return []
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return [`${y}-${m}`]
|
||||
}
|
||||
|
||||
private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> {
|
||||
const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase()
|
||||
if (!normalizedExpected) return true
|
||||
if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true
|
||||
try {
|
||||
const hash = crypto.createHash('md5')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = fs.createReadStream(sourcePath)
|
||||
stream.on('data', chunk => hash.update(chunk))
|
||||
stream.on('end', () => resolve())
|
||||
stream.on('error', reject)
|
||||
})
|
||||
return hash.digest('hex').toLowerCase() === normalizedExpected
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
||||
const fileName = String(msg?.fileName || '').trim()
|
||||
if (!fileName) return []
|
||||
|
||||
const roots = this.resolveFileAttachmentRoots()
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime)
|
||||
const candidates: FileExportCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const root of roots) {
|
||||
let monthDirs: string[] = []
|
||||
try {
|
||||
monthDirs = fs.readdirSync(root)
|
||||
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
|
||||
.sort()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const orderedMonths = Array.from(new Set([
|
||||
...preferredMonths,
|
||||
...monthDirs.slice().reverse()
|
||||
]))
|
||||
|
||||
for (const month of orderedMonths) {
|
||||
const sourcePath = path.join(root, month, fileName)
|
||||
if (!fs.existsSync(sourcePath)) continue
|
||||
const resolvedPath = path.resolve(sourcePath)
|
||||
if (seen.has(resolvedPath)) continue
|
||||
seen.add(resolvedPath)
|
||||
|
||||
if (normalizedMd5) {
|
||||
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
|
||||
if (ok) {
|
||||
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month })
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private async exportFileAttachment(
|
||||
msg: any,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
maxFileSizeMb?: number,
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||
if (!fileNameRaw) return null
|
||||
|
||||
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files')
|
||||
if (!dirCache?.has(filesDir)) {
|
||||
await fs.promises.mkdir(filesDir, { recursive: true })
|
||||
dirCache?.add(filesDir)
|
||||
}
|
||||
|
||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
const maxBytes = Number.isFinite(maxFileSizeMb)
|
||||
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
|
||||
: 0
|
||||
|
||||
const selected = candidates[0]
|
||||
const stat = await fs.promises.stat(selected.sourcePath)
|
||||
if (!stat.isFile()) return null
|
||||
if (maxBytes > 0 && stat.size > maxBytes) return null
|
||||
|
||||
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||
if (normalizedMd5 && selected.matchedBy !== 'md5') {
|
||||
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5)
|
||||
if (!verified) return null
|
||||
}
|
||||
|
||||
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
|
||||
const messageId = String(msg?.localId || Date.now())
|
||||
const destFileName = `${messageId}_${safeBaseName}`
|
||||
const destPath = path.join(filesDir, destFileName)
|
||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||
if (!copied.success) return null
|
||||
|
||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName),
|
||||
kind: 'file'
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private extractLocationMeta(content: string, localType: number): {
|
||||
locationLat?: number
|
||||
locationLng?: number
|
||||
@@ -3941,7 +4316,7 @@ class ExportService {
|
||||
mediaRelativePrefix: string
|
||||
} {
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||
const outputDir = path.dirname(outputPath)
|
||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||
@@ -4878,7 +5253,8 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) || // 图片
|
||||
(t === 47 && options.exportEmojis) || // 表情
|
||||
(t === 43 && options.exportVideos) || // 视频
|
||||
(t === 34 && options.exportVoices) // 语音文件
|
||||
(t === 34 && options.exportVoices) || // 语音文件
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -4919,6 +5295,8 @@ class ExportService {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
@@ -5066,6 +5444,11 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
|
||||
if (markdownLinkContent) {
|
||||
content = markdownLinkContent
|
||||
}
|
||||
|
||||
const message: ChatLabMessage = {
|
||||
sender: msg.senderUsername,
|
||||
accountName: senderProfile.displayName || memberInfo.accountName,
|
||||
@@ -5382,7 +5765,8 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -5422,6 +5806,8 @@ class ExportService {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
@@ -5558,6 +5944,13 @@ class ExportService {
|
||||
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||
}
|
||||
|
||||
const appendedLinkContent = quotedReplyDisplay
|
||||
? null
|
||||
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
|
||||
if (appendedLinkContent) {
|
||||
content = appendedLinkContent
|
||||
}
|
||||
|
||||
// 获取发送者信息用于名称显示
|
||||
const senderWxid = msg.senderUsername
|
||||
const contact = senderWxid
|
||||
@@ -6173,9 +6566,12 @@ class ExportService {
|
||||
currentRow++
|
||||
|
||||
// 表头行
|
||||
const includeGroupNicknameColumn = !useCompactColumns && isGroup
|
||||
const headers = useCompactColumns
|
||||
? ['序号', '时间', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
|
||||
: includeGroupNicknameColumn
|
||||
? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
|
||||
const headerRow = worksheet.getRow(currentRow)
|
||||
headerRow.height = 22
|
||||
|
||||
@@ -6203,10 +6599,16 @@ class ExportService {
|
||||
worksheet.getColumn(3).width = 18 // 发送者昵称
|
||||
worksheet.getColumn(4).width = 25 // 发送者微信ID
|
||||
worksheet.getColumn(5).width = 18 // 发送者备注
|
||||
worksheet.getColumn(6).width = 18 // 群昵称
|
||||
worksheet.getColumn(7).width = 15 // 发送者身份
|
||||
worksheet.getColumn(8).width = 12 // 消息类型
|
||||
worksheet.getColumn(9).width = 50 // 内容
|
||||
if (includeGroupNicknameColumn) {
|
||||
worksheet.getColumn(6).width = 18 // 群昵称
|
||||
worksheet.getColumn(7).width = 15 // 发送者身份
|
||||
worksheet.getColumn(8).width = 12 // 消息类型
|
||||
worksheet.getColumn(9).width = 50 // 内容
|
||||
} else {
|
||||
worksheet.getColumn(6).width = 15 // 发送者身份
|
||||
worksheet.getColumn(7).width = 12 // 消息类型
|
||||
worksheet.getColumn(8).width = 50 // 内容
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载群昵称 (仅群聊且完整列模式)
|
||||
@@ -6235,7 +6637,8 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -6275,6 +6678,8 @@ class ExportService {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
@@ -6484,24 +6889,31 @@ class ExportService {
|
||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
if (msg.localType === 3 || msg.localType === 47) {
|
||||
}
|
||||
const contentCellIndex = useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)
|
||||
const contentCell = worksheet.getCell(currentRow, contentCellIndex)
|
||||
|
||||
worksheet.getCell(currentRow, 1).value = i + 1
|
||||
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||
if (useCompactColumns) {
|
||||
worksheet.getCell(currentRow, 3).value = senderRole
|
||||
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 5).value = enrichedContentValue
|
||||
} else {
|
||||
} else if (includeGroupNicknameColumn) {
|
||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
||||
worksheet.getCell(currentRow, 7).value = senderRole
|
||||
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 9).value = enrichedContentValue
|
||||
} else {
|
||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||
worksheet.getCell(currentRow, 6).value = senderRole
|
||||
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||
}
|
||||
contentCell.value = enrichedContentValue
|
||||
if (!quotedReplyDisplay) {
|
||||
this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
|
||||
}
|
||||
|
||||
currentRow++
|
||||
@@ -6607,6 +7019,7 @@ class ExportService {
|
||||
})
|
||||
const worksheet = workbook.addWorksheet('聊天记录')
|
||||
const useCompactColumns = options.excelCompactColumns === true
|
||||
const includeGroupNicknameColumn = !useCompactColumns && isGroup
|
||||
const senderProfileCache = new Map<string, ExportDisplayProfile>()
|
||||
|
||||
worksheet.columns = useCompactColumns
|
||||
@@ -6617,17 +7030,28 @@ class ExportService {
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
: [
|
||||
{ width: 8 },
|
||||
{ width: 20 },
|
||||
{ width: 18 },
|
||||
{ width: 25 },
|
||||
{ width: 18 },
|
||||
{ width: 18 },
|
||||
{ width: 15 },
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
: includeGroupNicknameColumn
|
||||
? [
|
||||
{ width: 8 },
|
||||
{ width: 20 },
|
||||
{ width: 18 },
|
||||
{ width: 25 },
|
||||
{ width: 18 },
|
||||
{ width: 18 },
|
||||
{ width: 15 },
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
: [
|
||||
{ width: 8 },
|
||||
{ width: 20 },
|
||||
{ width: 18 },
|
||||
{ width: 25 },
|
||||
{ width: 18 },
|
||||
{ width: 15 },
|
||||
{ width: 12 },
|
||||
{ width: 50 }
|
||||
]
|
||||
|
||||
const appendRow = (values: any[]) => {
|
||||
const row = worksheet.addRow(values)
|
||||
@@ -6640,7 +7064,9 @@ class ExportService {
|
||||
appendRow([])
|
||||
appendRow(useCompactColumns
|
||||
? ['序号', '时间', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'])
|
||||
: includeGroupNicknameColumn
|
||||
? ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'])
|
||||
|
||||
for (let i = 0; i < totalMessages; i++) {
|
||||
if ((i & 0x7f) === 0) this.throwIfStopRequested(control)
|
||||
@@ -6747,7 +7173,7 @@ class ExportService {
|
||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||
}
|
||||
|
||||
appendRow(useCompactColumns
|
||||
const row = worksheet.addRow(useCompactColumns
|
||||
? [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
@@ -6755,17 +7181,36 @@ class ExportService {
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
]
|
||||
: [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
senderNickname,
|
||||
senderWxid,
|
||||
senderRemark,
|
||||
senderGroupNickname,
|
||||
senderRole,
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
])
|
||||
: includeGroupNicknameColumn
|
||||
? [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
senderNickname,
|
||||
senderWxid,
|
||||
senderRemark,
|
||||
senderGroupNickname,
|
||||
senderRole,
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
]
|
||||
: [
|
||||
i + 1,
|
||||
this.formatTimestamp(msg.createTime),
|
||||
senderNickname,
|
||||
senderWxid,
|
||||
senderRemark,
|
||||
senderRole,
|
||||
this.getMessageTypeName(msg.localType),
|
||||
enrichedContentValue
|
||||
])
|
||||
if (!quotedReplyDisplay) {
|
||||
this.applyExcelLinkCardCell(
|
||||
row.getCell(useCompactColumns ? 5 : (includeGroupNicknameColumn ? 9 : 8)),
|
||||
msg.content,
|
||||
msg.localType
|
||||
)
|
||||
}
|
||||
row.commit()
|
||||
|
||||
if ((i + 1) % 200 === 0) {
|
||||
onProgress?.({
|
||||
@@ -6943,7 +7388,8 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -6983,6 +7429,8 @@ class ExportService {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
@@ -7119,6 +7567,13 @@ class ExportService {
|
||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||
}
|
||||
|
||||
const appendedLinkContent = quotedReplyDisplay
|
||||
? null
|
||||
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
|
||||
if (appendedLinkContent) {
|
||||
enrichedContentValue = appendedLinkContent
|
||||
}
|
||||
|
||||
let senderRole: string
|
||||
let senderWxid: string
|
||||
let senderNickname: string
|
||||
@@ -7313,7 +7768,8 @@ class ExportService {
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 43 && options.exportVideos) ||
|
||||
(t === 34 && options.exportVoices)
|
||||
(t === 34 && options.exportVoices) ||
|
||||
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -7353,6 +7809,8 @@ class ExportService {
|
||||
exportVoices: options.exportVoices,
|
||||
exportVideos: options.exportVideos,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
@@ -7773,6 +8231,8 @@ class ExportService {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportFiles: options.exportFiles,
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
@@ -8311,22 +8771,22 @@ class ExportService {
|
||||
|
||||
const metric = aggregatedData?.[sessionId]
|
||||
const totalCount = Number.isFinite(metric?.totalMessages)
|
||||
? Math.max(0, Math.floor(metric!.totalMessages))
|
||||
? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
|
||||
: 0
|
||||
const voiceCount = Number.isFinite(metric?.voiceMessages)
|
||||
? Math.max(0, Math.floor(metric!.voiceMessages))
|
||||
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
|
||||
: 0
|
||||
const imageCount = Number.isFinite(metric?.imageMessages)
|
||||
? Math.max(0, Math.floor(metric!.imageMessages))
|
||||
? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
|
||||
: 0
|
||||
const videoCount = Number.isFinite(metric?.videoMessages)
|
||||
? Math.max(0, Math.floor(metric!.videoMessages))
|
||||
? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
|
||||
: 0
|
||||
const emojiCount = Number.isFinite(metric?.emojiMessages)
|
||||
? Math.max(0, Math.floor(metric!.emojiMessages))
|
||||
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
|
||||
: 0
|
||||
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
|
||||
? Math.max(0, Math.floor(metric!.lastTimestamp))
|
||||
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
|
||||
: undefined
|
||||
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
|
||||
const sessionCachedVoiceCount = Math.min(
|
||||
@@ -8526,6 +8986,7 @@ class ExportService {
|
||||
? path.join(outputDir, 'texts')
|
||||
: outputDir
|
||||
const createdTaskDirs = new Set<string>()
|
||||
const reservedOutputPaths = new Set<string>()
|
||||
const ensureTaskDir = async (dirPath: string) => {
|
||||
if (createdTaskDirs.has(dirPath)) return
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
@@ -8774,10 +9235,8 @@ class ExportService {
|
||||
phaseLabel: '准备导出'
|
||||
})
|
||||
|
||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
|
||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||
const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
|
||||
const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
|
||||
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
|
||||
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
|
||||
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
|
||||
@@ -8795,13 +9254,13 @@ class ExportService {
|
||||
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
||||
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
||||
else if (effectiveOptions.format === 'html') ext = '.html'
|
||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
|
||||
typeof messageCountHint === 'number' &&
|
||||
messageCountHint >= 0 &&
|
||||
typeof latestTimestampHint === 'number' &&
|
||||
latestTimestampHint > 0 &&
|
||||
await this.pathExists(outputPath)
|
||||
await this.pathExists(preferredOutputPath)
|
||||
if (canTrySkipUnchanged) {
|
||||
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
||||
const hasNoDataChange = Boolean(
|
||||
@@ -8828,6 +9287,10 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = fileNamingMode === 'date-range'
|
||||
? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
|
||||
: preferredOutputPath
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||
|
||||
@@ -275,7 +275,7 @@ class GroupAnalyticsService {
|
||||
}
|
||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||
console.error('getGroupNicknamesForRoom service error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import * as http from 'http'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { videoService } from './videoService'
|
||||
import { imageDecryptService } from './imageDecryptService'
|
||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||
import { snsService } from './snsService'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
@@ -267,9 +269,19 @@ class HttpService {
|
||||
*/
|
||||
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||
if (req.method !== 'POST') return {}
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
return new Promise((resolve) => {
|
||||
let body = ''
|
||||
req.on('data', chunk => { body += chunk.toString() })
|
||||
let bodySize = 0
|
||||
req.on('data', chunk => {
|
||||
bodySize += chunk.length
|
||||
if (bodySize > MAX_BODY_SIZE) {
|
||||
req.destroy()
|
||||
resolve({})
|
||||
return
|
||||
}
|
||||
body += chunk.toString()
|
||||
})
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
@@ -284,31 +296,45 @@ class HttpService {
|
||||
/**
|
||||
* 鉴权拦截器
|
||||
*/
|
||||
private safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||
if (!expectedToken) return true
|
||||
if (!expectedToken) {
|
||||
// token 未配置时拒绝所有请求,防止未授权访问
|
||||
console.warn('[HttpService] Access denied: httpApiToken not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7).trim()
|
||||
if (token === expectedToken) return true
|
||||
if (this.safeEqual(token, expectedToken)) return true
|
||||
}
|
||||
|
||||
const queryToken = url.searchParams.get('access_token')
|
||||
if (queryToken && queryToken.trim() === expectedToken) return true
|
||||
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
|
||||
|
||||
const bodyToken = body['access_token']
|
||||
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
|
||||
|
||||
|
||||
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求 (重构后)
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
// 仅允许本地来源的跨域请求
|
||||
const origin = req.headers.origin || ''
|
||||
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -348,6 +374,33 @@ class HttpService {
|
||||
await this.handleContacts(url, res)
|
||||
} else if (pathname === '/api/v1/group-members') {
|
||||
await this.handleGroupMembers(url, res)
|
||||
} else if (pathname === '/api/v1/sns/timeline') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsTimeline(url, res)
|
||||
} else if (pathname === '/api/v1/sns/usernames') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsUsernames(res)
|
||||
} else if (pathname === '/api/v1/sns/export/stats') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsExportStats(url, res)
|
||||
} else if (pathname === '/api/v1/sns/media/proxy') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsMediaProxy(url, res)
|
||||
} else if (pathname === '/api/v1/sns/export') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsExport(url, res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/status') {
|
||||
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||
await this.handleSnsBlockDeleteStatus(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/install') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteInstall(res)
|
||||
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
|
||||
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||
await this.handleSnsBlockDeleteUninstall(res)
|
||||
} else if (pathname.startsWith('/api/v1/sns/post/')) {
|
||||
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
|
||||
await this.handleSnsDeletePost(pathname, res)
|
||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||
this.handleMediaRequest(pathname, res)
|
||||
} else {
|
||||
@@ -403,9 +456,15 @@ class HttpService {
|
||||
}
|
||||
|
||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||
const mediaBasePath = this.getApiMediaExportPath()
|
||||
const mediaBasePath = path.resolve(this.getApiMediaExportPath())
|
||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||
const fullPath = path.join(mediaBasePath, relativePath)
|
||||
const fullPath = path.resolve(mediaBasePath, relativePath)
|
||||
|
||||
// 防止路径穿越攻击
|
||||
if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) {
|
||||
this.sendError(res, 403, 'Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
this.sendError(res, 404, 'Media not found')
|
||||
@@ -559,6 +618,15 @@ class HttpService {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
private parseStringListParam(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
const values = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
return values.length > 0 ? Array.from(new Set(values)) : undefined
|
||||
}
|
||||
|
||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||
if (!mediaEnabled) {
|
||||
@@ -790,6 +858,313 @@ class HttpService {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
|
||||
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
|
||||
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
|
||||
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
|
||||
|
||||
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
|
||||
return
|
||||
}
|
||||
|
||||
let timeline = result.timeline || []
|
||||
if (resolveMedia && timeline.length > 0) {
|
||||
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
|
||||
}
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: timeline.length,
|
||||
timeline
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
usernames: result.usernames || []
|
||||
})
|
||||
}
|
||||
|
||||
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const fast = this.parseBooleanParam(url, ['fast'], false)
|
||||
const result = fast
|
||||
? await snsService.getExportStatsFast()
|
||||
: await snsService.getExportStats()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const mediaUrl = (url.searchParams.get('url') || '').trim()
|
||||
if (!mediaUrl) {
|
||||
this.sendError(res, 400, 'Missing required parameter: url')
|
||||
return
|
||||
}
|
||||
|
||||
const key = this.toSnsMediaKey(url.searchParams.get('key'))
|
||||
const result = await snsService.downloadImage(mediaUrl, key)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', result.data.length)
|
||||
res.writeHead(200)
|
||||
res.end(result.data)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.cachePath && fs.existsSync(result.cachePath)) {
|
||||
try {
|
||||
const stat = fs.statSync(result.cachePath)
|
||||
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||
res.setHeader('Content-Length', stat.size)
|
||||
res.writeHead(200)
|
||||
|
||||
const stream = fs.createReadStream(result.cachePath)
|
||||
stream.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
this.sendError(res, 500, 'Failed to read proxied sns media')
|
||||
} else {
|
||||
try { res.destroy() } catch {}
|
||||
}
|
||||
})
|
||||
stream.pipe(res)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Failed to stream sns media cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||
}
|
||||
|
||||
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
|
||||
if (!outputDir) {
|
||||
this.sendError(res, 400, 'Missing required field: outputDir')
|
||||
return
|
||||
}
|
||||
|
||||
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||
return
|
||||
}
|
||||
|
||||
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
|
||||
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||
|
||||
const options: {
|
||||
outputDir: string
|
||||
format: 'json' | 'html' | 'arkmejson'
|
||||
usernames?: string[]
|
||||
keyword?: string
|
||||
exportMedia?: boolean
|
||||
exportImages?: boolean
|
||||
exportLivePhotos?: boolean
|
||||
exportVideos?: boolean
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
} = {
|
||||
outputDir,
|
||||
format: format as 'json' | 'html' | 'arkmejson',
|
||||
usernames,
|
||||
keyword,
|
||||
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||
}
|
||||
|
||||
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
|
||||
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
|
||||
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
|
||||
if (startTimeRaw > 0) options.startTime = startTimeRaw
|
||||
if (endTimeRaw > 0) options.endTime = endTimeRaw
|
||||
|
||||
const result = await snsService.exportTimeline(options)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.checkSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.installSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
|
||||
const result = await snsService.uninstallSnsBlockDeleteTrigger()
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
|
||||
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
|
||||
if (!postId) {
|
||||
this.sendError(res, 400, 'Missing required path parameter: postId')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await snsService.deleteSnsPost(postId)
|
||||
if (!result.success) {
|
||||
this.sendError(res, 500, result.error || 'Failed to delete sns post')
|
||||
return
|
||||
}
|
||||
this.sendJson(res, result)
|
||||
}
|
||||
|
||||
private toSnsMediaKey(value: unknown): string | number | undefined {
|
||||
if (value == null) return undefined
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
const text = String(value).trim()
|
||||
if (!text) return undefined
|
||||
if (/^-?\d+$/.test(text)) return Number(text)
|
||||
return text
|
||||
}
|
||||
|
||||
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
|
||||
const target = String(rawUrl || '').trim()
|
||||
if (!target) return undefined
|
||||
const params = new URLSearchParams({ url: target })
|
||||
if (key !== undefined) params.set('key', String(key))
|
||||
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
|
||||
}
|
||||
|
||||
private async resolveSnsMediaUrl(
|
||||
rawUrl: string,
|
||||
key: string | number | undefined,
|
||||
inline: boolean
|
||||
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
|
||||
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
|
||||
if (!proxyUrl) return {}
|
||||
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
|
||||
try {
|
||||
const resolved = await snsService.proxyImage(rawUrl, key)
|
||||
if (resolved.success && resolved.dataUrl) {
|
||||
return { resolvedUrl: resolved.dataUrl, proxyUrl }
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
|
||||
}
|
||||
|
||||
return { resolvedUrl: proxyUrl, proxyUrl }
|
||||
}
|
||||
|
||||
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
|
||||
return Promise.all(
|
||||
(posts || []).map(async (post) => {
|
||||
const mediaList = Array.isArray(post?.media) ? post.media : []
|
||||
if (mediaList.length === 0) return post
|
||||
|
||||
const nextMedia = await Promise.all(
|
||||
mediaList.map(async (media: any) => {
|
||||
const rawUrl = typeof media?.url === 'string' ? media.url : ''
|
||||
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
|
||||
const mediaKey = this.toSnsMediaKey(media?.key)
|
||||
|
||||
const [urlResolved, thumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
|
||||
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
|
||||
])
|
||||
|
||||
const nextItem: any = {
|
||||
...media,
|
||||
rawUrl,
|
||||
rawThumb,
|
||||
resolvedUrl: urlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: thumbResolved.resolvedUrl,
|
||||
proxyUrl: urlResolved.proxyUrl,
|
||||
proxyThumbUrl: thumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextItem.url = urlResolved.resolvedUrl || rawUrl
|
||||
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
|
||||
}
|
||||
|
||||
if (media?.livePhoto && typeof media.livePhoto === 'object') {
|
||||
const livePhoto = media.livePhoto
|
||||
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
|
||||
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
|
||||
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
|
||||
|
||||
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
|
||||
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
|
||||
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
|
||||
])
|
||||
|
||||
const nextLive: any = {
|
||||
...livePhoto,
|
||||
rawUrl: rawLiveUrl,
|
||||
rawThumb: rawLiveThumb,
|
||||
resolvedUrl: liveUrlResolved.resolvedUrl,
|
||||
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
|
||||
proxyUrl: liveUrlResolved.proxyUrl,
|
||||
proxyThumbUrl: liveThumbResolved.proxyUrl
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
|
||||
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
|
||||
}
|
||||
|
||||
nextItem.livePhoto = nextLive
|
||||
}
|
||||
|
||||
return nextItem
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
...post,
|
||||
media: nextMedia
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private getApiMediaExportPath(): string {
|
||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||
}
|
||||
@@ -1451,6 +1826,11 @@ class HttpService {
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
|
||||
res.setHeader('Allow', allow)
|
||||
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
|
||||
@@ -55,11 +55,15 @@ type DecryptResult = {
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
|
||||
type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -113,7 +117,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -126,7 +132,9 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
@@ -146,7 +154,9 @@ export class ImageDecryptService {
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
@@ -167,6 +177,7 @@ export class ImageDecryptService {
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
|
||||
|
||||
if (payload.force) {
|
||||
for (const key of cacheKeys) {
|
||||
@@ -176,6 +187,7 @@ export class ImageDecryptService {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -191,6 +203,7 @@ export class ImageDecryptService {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
@@ -201,6 +214,7 @@ export class ImageDecryptService {
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -209,7 +223,10 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const pending = this.pending.get(cacheKey)
|
||||
if (pending) return pending
|
||||
if (pending) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
|
||||
return pending
|
||||
}
|
||||
|
||||
const task = this.decryptImageInternal(payload, cacheKey)
|
||||
this.pending.set(cacheKey, task)
|
||||
@@ -261,49 +278,93 @@ export class ImageDecryptService {
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
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')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
const datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: !payload.force,
|
||||
skipResolvedCache: Boolean(payload.force),
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
let datPath: string | null = null
|
||||
let usedHdAttempt = false
|
||||
let fallbackToThumbnail = false
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
// force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
|
||||
if (payload.force) {
|
||||
usedHdAttempt = true
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: false,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
if (!datPath) {
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
fallbackToThumbnail = Boolean(datPath)
|
||||
if (fallbackToThumbnail) {
|
||||
this.logInfo('高清缺失,回退解密缩略图', {
|
||||
md5: payload.imageMd5,
|
||||
datName: payload.imageDatName
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!datPath) {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
|
||||
if (usedHdAttempt) {
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
|
||||
}
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
@@ -319,6 +380,7 @@ export class ImageDecryptService {
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
@@ -340,6 +402,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
|
||||
@@ -347,7 +410,9 @@ export class ImageDecryptService {
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
@@ -363,10 +428,12 @@ export class ImageDecryptService {
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
if (finalExt === '.hevc') {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
@@ -378,15 +445,19 @@ export class ImageDecryptService {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
} else {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
}
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -562,14 +633,14 @@ export class ImageDecryptService {
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
@@ -605,41 +676,53 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
|
||||
this.resolvedCache.set(imageDatName, datPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
return datPath
|
||||
}
|
||||
const normalized = this.normalizeDatBase(imageDatName)
|
||||
if (normalized !== imageDatName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
|
||||
this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
return normalizedPath
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -866,7 +949,7 @@ export class ImageDecryptService {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
@@ -974,7 +1057,7 @@ export class ImageDecryptService {
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -990,8 +1073,10 @@ export class ImageDecryptService {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
@@ -1002,9 +1087,13 @@ export class ImageDecryptService {
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
@@ -1288,6 +1377,31 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private emitDecryptProgress(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
cacheKey: string,
|
||||
stage: DecryptProgressStage,
|
||||
progress: number,
|
||||
status: 'running' | 'done' | 'error',
|
||||
message?: string
|
||||
): void {
|
||||
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
|
||||
const event = {
|
||||
cacheKey,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
stage,
|
||||
progress: safeProgress,
|
||||
status,
|
||||
message: message || ''
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:decryptProgress', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
@@ -1740,7 +1854,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
|
||||
@@ -6,36 +6,60 @@ type PreloadImagePayload = {
|
||||
imageDatName?: string
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
private queue: PreloadTask[] = []
|
||||
private pending = new Set<string>()
|
||||
private active = 0
|
||||
private readonly maxConcurrent = 2
|
||||
private activeCache = 0
|
||||
private activeDecrypt = 0
|
||||
private readonly maxCacheConcurrent = 8
|
||||
private readonly maxDecryptConcurrent = 2
|
||||
private readonly maxQueueSize = 320
|
||||
|
||||
enqueue(payloads: PreloadImagePayload[]): void {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) continue
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
||||
const task = this.queue.shift()
|
||||
while (this.queue.length > 0) {
|
||||
const taskIndex = this.queue.findIndex((task) => (
|
||||
task.allowDecrypt
|
||||
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||
: this.activeCache < this.maxCacheConcurrent
|
||||
))
|
||||
if (taskIndex < 0) return
|
||||
|
||||
const task = this.queue.splice(taskIndex, 1)[0]
|
||||
if (!task) return
|
||||
this.active += 1
|
||||
|
||||
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||
else this.activeCache += 1
|
||||
|
||||
void this.handleTask(task).finally(() => {
|
||||
this.active -= 1
|
||||
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
|
||||
else this.activeCache = Math.max(0, this.activeCache - 1)
|
||||
this.pending.delete(task.key)
|
||||
this.processQueue()
|
||||
})
|
||||
@@ -49,9 +73,12 @@ export class ImagePreloadService {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
|
||||
987
electron/services/insightService.ts
Normal file
987
electron/services/insightService.ts
Normal file
@@ -0,0 +1,987 @@
|
||||
/**
|
||||
* insightService.ts
|
||||
*
|
||||
* AI 见解后台服务:
|
||||
* 1. 监听 DB 变更事件(debounce 500ms 防抖,避免开机/重连时爆发大量事件阻塞主线程)
|
||||
* 2. 沉默联系人扫描(独立 setInterval,每 4 小时一次)
|
||||
* 3. 触发后拉取真实聊天上下文(若用户授权),组装 prompt 调用单一 AI 模型
|
||||
* 4. 输出 ≤80 字见解,通过现有 showNotification 弹出右下角通知
|
||||
*
|
||||
* 设计原则:
|
||||
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
|
||||
* - 所有失败静默处理,不影响主流程
|
||||
* - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
|
||||
*/
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* DB 变更防抖延迟(毫秒)。
|
||||
* 设为 2s:微信写库通常是批量操作,500ms 过短会在开机/重连时产生大量连续触发。
|
||||
*/
|
||||
const DB_CHANGE_DEBOUNCE_MS = 2000
|
||||
|
||||
/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */
|
||||
const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||
|
||||
/** 单次 API 请求超时(毫秒) */
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiInsightEnabled',
|
||||
'aiInsightScanIntervalHours',
|
||||
'aiModelApiBaseUrl',
|
||||
'aiModelApiKey',
|
||||
'aiModelApiModel',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TodayTriggerRecord {
|
||||
/** 该会话今日触发的时间戳列表(毫秒) */
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
interface SharedAiModelConfig {
|
||||
apiBaseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 仅输出到 console,不落盘到文件。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。
|
||||
*
|
||||
* 例如:
|
||||
* baseUrl = "https://api.ohmygpt.com/v1"
|
||||
* path = "/chat/completions"
|
||||
* 结果为 "https://api.ohmygpt.com/v1/chat/completions"
|
||||
*
|
||||
* 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)),
|
||||
* 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地
|
||||
* 丢弃 v1,变成 https://api.example.com/chat/completions。
|
||||
*/
|
||||
function buildApiUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function getStartOfDay(date: Date = new Date()): number {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
*/
|
||||
function callApi(
|
||||
apiBaseUrl: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(endpoint)
|
||||
} catch (e) {
|
||||
reject(new Error(`无效的 API URL: ${endpoint}`))
|
||||
return
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
stream: false
|
||||
})
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'POST' as const,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString(),
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
|
||||
const isHttps = urlObj.protocol === 'https:'
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
const req = requestFn(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed?.choices?.[0]?.message?.content
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
resolve(content.trim())
|
||||
} else {
|
||||
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy()
|
||||
reject(new Error('API 请求超时'))
|
||||
})
|
||||
|
||||
req.on('error', (e) => reject(e))
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// ─── InsightService 主类 ──────────────────────────────────────────────────────
|
||||
|
||||
class InsightService {
|
||||
private readonly config: ConfigService
|
||||
|
||||
/** DB 变更防抖定时器 */
|
||||
private dbDebounceTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** 沉默扫描定时器 */
|
||||
private silenceScanTimer: NodeJS.Timeout | null = null
|
||||
private silenceInitialDelayTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** 是否正在处理中(防重入) */
|
||||
private processing = false
|
||||
|
||||
/**
|
||||
* 当日触发记录:sessionId -> TodayTriggerRecord
|
||||
* 每天 00:00 之后自动重置(通过检查日期实现)
|
||||
*/
|
||||
private todayTriggers: Map<string, TodayTriggerRecord> = new Map()
|
||||
private todayDate = getStartOfDay()
|
||||
|
||||
/**
|
||||
* 活跃分析冷却记录:sessionId -> 上次分析时间戳(毫秒)
|
||||
* 同一会话 2 小时内不重复触发活跃分析,防止 DB 频繁变更时爆量调用 API。
|
||||
*/
|
||||
private lastActivityAnalysis: Map<string, number> = new Map()
|
||||
|
||||
/**
|
||||
* 跟踪每个会话上次见到的最新消息时间戳,用于判断是否有真正的新消息。
|
||||
* sessionId -> lastMessageTimestamp(秒,与微信 DB 保持一致)
|
||||
*/
|
||||
private lastSeenTimestamp: Map<string, number> = new Map()
|
||||
|
||||
/**
|
||||
* 本地会话快照缓存,避免 analyzeRecentActivity 在每次 DB 变更时都做全量读取。
|
||||
* 首次调用时填充,此后只在沉默扫描里刷新(沉默扫描间隔更长,更合适做全量刷新)。
|
||||
*/
|
||||
private sessionCache: ChatSession[] | null = null
|
||||
/** sessionCache 最后刷新时间戳(ms),超过 15 分钟强制重新拉取 */
|
||||
private sessionCacheAt = 0
|
||||
/** 缓存 TTL 设为 15 分钟,大幅减少 connect() + getSessions() 调用频率 */
|
||||
private static readonly SESSION_CACHE_TTL_MS = 15 * 60 * 1000
|
||||
/** 数据库是否已连接(避免重复调用 chatService.connect()) */
|
||||
private dbConnected = false
|
||||
|
||||
private started = false
|
||||
|
||||
constructor() {
|
||||
this.config = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
// ── 公开 API ────────────────────────────────────────────────────────────────
|
||||
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const hadActiveFlow =
|
||||
this.dbDebounceTimer !== null ||
|
||||
this.silenceScanTimer !== null ||
|
||||
this.silenceInitialDelayTimer !== null ||
|
||||
this.processing
|
||||
this.started = false
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
if (hadActiveFlow) {
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
|
||||
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
}
|
||||
|
||||
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
return
|
||||
}
|
||||
this.scheduleSilenceScan()
|
||||
}
|
||||
|
||||
private clearRuntimeCache(): void {
|
||||
this.dbConnected = false
|
||||
this.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
this.lastActivityAnalysis.clear()
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
}
|
||||
if (this.silenceScanTimer !== null) {
|
||||
clearTimeout(this.silenceScanTimer)
|
||||
this.silenceScanTimer = null
|
||||
}
|
||||
if (this.silenceInitialDelayTimer !== null) {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 main.ts 在 addDbMonitorListener 回调中调用。
|
||||
* 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。
|
||||
* 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。
|
||||
*/
|
||||
handleDbMonitorChange(_type: string, _json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) return
|
||||
// 正在处理时忽略新事件,避免 timer 堆积
|
||||
if (this.processing) return
|
||||
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
}
|
||||
this.dbDebounceTimer = setTimeout(() => {
|
||||
this.dbDebounceTimer = null
|
||||
void this.analyzeRecentActivity()
|
||||
}, DB_CHANGE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 API 连接,返回 { success, message }。
|
||||
* 供设置页"测试连接"按钮调用。
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 API Key' }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
|
||||
15_000
|
||||
)
|
||||
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||
} catch (e) {
|
||||
return { success: false, message: `连接失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即对最近一个私聊会话触发一次见解(忽略冷却,用于测试)。
|
||||
* 返回触发结果描述,供设置页展示。
|
||||
*/
|
||||
async triggerTest(): Promise<{ success: boolean; message: string }> {
|
||||
insightLog('INFO', '手动触发测试见解...')
|
||||
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 Key' }
|
||||
}
|
||||
try {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' }
|
||||
}
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions || sessionsResult.sessions.length === 0) {
|
||||
return { success: false, message: '未找到任何会话,请确认数据库已正确连接' }
|
||||
}
|
||||
// 找第一个允许的私聊
|
||||
const session = (sessionsResult.sessions as ChatSession[]).find((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||
})
|
||||
if (!session) {
|
||||
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
||||
}
|
||||
const sessionId = session.username?.trim() || ''
|
||||
const displayName = session.displayName || sessionId
|
||||
insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`)
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||
} catch (e) {
|
||||
return { success: false, message: `测试失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取今日触发统计(供设置页展示) */
|
||||
getTodayStats(): { sessionId: string; count: number; times: string[] }[] {
|
||||
this.resetIfNewDay()
|
||||
const result: { sessionId: string; count: number; times: string[] }[] = []
|
||||
for (const [sessionId, record] of this.todayTriggers.entries()) {
|
||||
result.push({
|
||||
sessionId,
|
||||
count: record.timestamps.length,
|
||||
times: record.timestamps.map(formatTimestamp)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async generateFootprintInsight(params: {
|
||||
rangeLabel: string
|
||||
summary: {
|
||||
private_inbound_people?: number
|
||||
private_replied_people?: number
|
||||
private_outbound_people?: number
|
||||
private_reply_rate?: number
|
||||
mention_count?: number
|
||||
mention_group_count?: number
|
||||
}
|
||||
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||
}): Promise<{ success: boolean; message: string; insight?: string }> {
|
||||
const enabled = this.config.get('aiFootprintEnabled') === true
|
||||
if (!enabled) {
|
||||
return { success: false, message: '请先在设置中开启「AI 足迹总结」' }
|
||||
}
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
|
||||
const summary = params?.summary || {}
|
||||
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
|
||||
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
|
||||
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
|
||||
|
||||
const topPrivateText = privateSegments.length > 0
|
||||
? privateSegments
|
||||
.map((item, idx) => {
|
||||
const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim()
|
||||
const inbound = Number(item.incoming_count) || 0
|
||||
const outbound = Number(item.outgoing_count) || 0
|
||||
const total = Math.max(Number(item.message_count) || 0, inbound + outbound)
|
||||
return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''})`
|
||||
})
|
||||
.join('\n')
|
||||
: '无'
|
||||
|
||||
const topMentionText = mentionGroups.length > 0
|
||||
? mentionGroups
|
||||
.map((item, idx) => {
|
||||
const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim()
|
||||
const count = Number(item.count) || 0
|
||||
return `${idx + 1}. ${name}(@我 ${count} 次)`
|
||||
})
|
||||
.join('\n')
|
||||
: '无'
|
||||
|
||||
const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
|
||||
要求:
|
||||
1. 输出 2-3 句,总长度不超过 180 字。
|
||||
2. 必须包含:总体观察 + 一个可执行建议。
|
||||
3. 语气务实,不夸张,不使用 Markdown。`
|
||||
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || defaultSystemPrompt
|
||||
|
||||
const userPrompt = `统计范围:${rangeLabel}
|
||||
有聊天的人数:${Number(summary.private_inbound_people) || 0}
|
||||
我有回复的人数:${Number(summary.private_outbound_people) || 0}
|
||||
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
|
||||
@我次数:${Number(summary.mention_count) || 0}
|
||||
涉及群聊:${Number(summary.mention_group_count) || 0}
|
||||
|
||||
私聊重点:
|
||||
${topPrivateText}
|
||||
|
||||
群聊@我重点:
|
||||
${topMentionText}
|
||||
|
||||
请给出足迹复盘(2-3句,含建议):`
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
25_000
|
||||
)
|
||||
const insight = result.trim().slice(0, 400)
|
||||
if (!insight) return { success: false, message: '模型返回为空' }
|
||||
return { success: true, message: '生成成功', insight }
|
||||
} catch (error) {
|
||||
return { success: false, message: `生成失败:${(error as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.config.get('aiInsightEnabled') === true
|
||||
}
|
||||
|
||||
private getSharedAiModelConfig(): SharedAiModelConfig {
|
||||
const apiBaseUrl = String(
|
||||
this.config.get('aiModelApiBaseUrl')
|
||||
|| this.config.get('aiInsightApiBaseUrl')
|
||||
|| ''
|
||||
).trim()
|
||||
const apiKey = String(
|
||||
this.config.get('aiModelApiKey')
|
||||
|| this.config.get('aiInsightApiKey')
|
||||
|| ''
|
||||
).trim()
|
||||
const model = String(
|
||||
this.config.get('aiModelApiModel')
|
||||
|| this.config.get('aiInsightApiModel')
|
||||
|| 'gpt-4o-mini'
|
||||
).trim() || 'gpt-4o-mini'
|
||||
|
||||
return { apiBaseUrl, apiKey, model }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个会话是否允许触发见解。
|
||||
* 若白名单未启用,则所有私聊会话均允许;
|
||||
* 若白名单已启用,则只有在白名单中的会话才允许。
|
||||
*/
|
||||
private isSessionAllowed(sessionId: string): boolean {
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
if (!whitelistEnabled) return true
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
return whitelist.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表,优先使用缓存(15 分钟 TTL)。
|
||||
* 缓存命中时完全跳过数据库访问,避免频繁 connect() + getSessions() 消耗 CPU。
|
||||
* forceRefresh=true 时强制重新拉取(仅用于沉默扫描等低频场景)。
|
||||
*/
|
||||
private async getSessionsCached(forceRefresh = false): Promise<ChatSession[]> {
|
||||
const now = Date.now()
|
||||
// 缓存命中:直接返回,零数据库操作
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.sessionCache !== null &&
|
||||
now - this.sessionCacheAt < InsightService.SESSION_CACHE_TTL_MS
|
||||
) {
|
||||
return this.sessionCache
|
||||
}
|
||||
// 缓存未命中或强制刷新:连接数据库并拉取
|
||||
try {
|
||||
// 只在首次或强制刷新时调用 connect(),避免重复建立连接
|
||||
if (!this.dbConnected || forceRefresh) {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
insightLog('WARN', '数据库连接失败,使用旧缓存')
|
||||
return this.sessionCache ?? []
|
||||
}
|
||||
this.dbConnected = true
|
||||
}
|
||||
const result = await chatService.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
this.sessionCache = result.sessions as ChatSession[]
|
||||
this.sessionCacheAt = now
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `获取会话缓存失败: ${(e as Error).message}`)
|
||||
// 连接可能已断开,下次强制重连
|
||||
this.dbConnected = false
|
||||
}
|
||||
return this.sessionCache ?? []
|
||||
}
|
||||
|
||||
private resetIfNewDay(): void {
|
||||
const todayStart = getStartOfDay()
|
||||
if (todayStart > this.todayDate) {
|
||||
this.todayDate = todayStart
|
||||
this.todayTriggers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。
|
||||
*/
|
||||
private recordTrigger(sessionId: string): string[] {
|
||||
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 scheduleSilenceScan(): void {
|
||||
this.clearTimers()
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
this.silenceScanTimer = setTimeout(async () => {
|
||||
this.silenceScanTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
this.silenceInitialDelayTimer = setTimeout(async () => {
|
||||
this.silenceInitialDelayTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, SILENCE_SCAN_INITIAL_DELAY_MS)
|
||||
}
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
insightLog('INFO', '沉默扫描:正在处理中,跳过本次')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
insightLog('INFO', '开始沉默联系人扫描...')
|
||||
try {
|
||||
const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS
|
||||
const thresholdMs = silenceDays * 24 * 60 * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
insightLog('INFO', `沉默阈值:${silenceDays} 天`)
|
||||
|
||||
// 沉默扫描间隔较长,强制刷新缓存以获取最新数据
|
||||
const sessions = await this.getSessionsCached(true)
|
||||
if (sessions.length === 0) {
|
||||
insightLog('WARN', '获取会话列表失败,跳过沉默扫描')
|
||||
return
|
||||
}
|
||||
|
||||
insightLog('INFO', `共 ${sessions.length} 个会话,开始过滤...`)
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
if (!this.isEnabled()) return
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
if (!this.isSessionAllowed(sessionId)) continue
|
||||
|
||||
const lastTimestamp = (session.lastTimestamp || 0) * 1000
|
||||
if (!lastTimestamp || lastTimestamp <= 0) continue
|
||||
|
||||
const silentMs = now - lastTimestamp
|
||||
if (silentMs < thresholdMs) continue
|
||||
|
||||
silentCount++
|
||||
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
|
||||
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'silence',
|
||||
silentDays
|
||||
})
|
||||
}
|
||||
insightLog('INFO', `沉默扫描完成,共发现 ${silentCount} 个沉默联系人`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `沉默扫描出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 活跃会话分析 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 在 DB 变更防抖后执行,分析最近活跃的会话。
|
||||
*
|
||||
* 触发条件(必须同时满足):
|
||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||
* 2. 该会话距上次活跃分析已超过冷却期
|
||||
*
|
||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||
*/
|
||||
private async analyzeRecentActivity(): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (this.processing) return
|
||||
|
||||
this.processing = true
|
||||
try {
|
||||
const now = Date.now()
|
||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
|
||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||
if (whitelistEnabled && whitelist.length > 0) {
|
||||
// 确保数据库已连接(首次时连接,之后复用)
|
||||
if (!this.dbConnected) {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) return
|
||||
this.dbConnected = true
|
||||
}
|
||||
|
||||
for (const sessionId of whitelist) {
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
|
||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
// 拉取最新 1 条消息,用时间戳判断是否有新消息,避免全量 getSessions()
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, 1)
|
||||
if (!msgsResult.success || !msgsResult.messages || msgsResult.messages.length === 0) continue
|
||||
|
||||
const latestMsg = msgsResult.messages[0]
|
||||
const latestTs = Number(latestMsg.createTime) || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
|
||||
if (latestTs <= lastSeen) continue // 没有新消息
|
||||
this.lastSeenTimestamp.set(sessionId, latestTs)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `白名单会话 ${sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
// displayName 使用白名单 sessionId,generateInsightForSession 内部会从上下文里获取真实名称
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: sessionId,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break // 每次最多处理 1 个会话
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||
const sessions = await this.getSessionsCached()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||
})
|
||||
|
||||
for (const session of privateSessions.slice(0, 10)) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId) continue
|
||||
|
||||
const currentTimestamp = session.lastTimestamp || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
if (currentTimestamp <= lastSeen) continue
|
||||
this.lastSeenTimestamp.set(sessionId, currentTimestamp)
|
||||
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `活跃分析出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 核心见解生成 ────────────────────────────────────────────────────────────
|
||||
|
||||
private async generateInsightForSession(params: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
triggerReason: 'activity' | 'silence'
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
|
||||
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
|
||||
return
|
||||
}
|
||||
|
||||
// ── 构建 prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
// 今日触发统计(让模型具备时间与克制感)
|
||||
const sessionTriggerTimes = this.recordTrigger(sessionId)
|
||||
const totalTodayTriggers = this.getTodayTotalTriggerCount()
|
||||
|
||||
let contextSection = ''
|
||||
if (allowContext) {
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
|
||||
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
|
||||
const messages: Message[] = msgsResult.messages
|
||||
const msgLines = messages.map((m) => {
|
||||
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
|
||||
const content = m.rawContent || m.parsedContent || '[非文字消息]'
|
||||
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
|
||||
return `[${time}] ${sender}:${content}`
|
||||
})
|
||||
contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
|
||||
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||
|
||||
要求:
|
||||
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
|
||||
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
|
||||
3. 输出纯文本,不使用 Markdown。
|
||||
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
|
||||
|
||||
// 优先使用用户自定义 prompt,为空则使用默认值
|
||||
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} 天没有和「${displayName}」聊天了。`
|
||||
: `你最近和「${displayName}」有新的聊天动态。`
|
||||
|
||||
const todayStatsDesc =
|
||||
sessionTriggerTimes.length > 1
|
||||
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||
: `今天你还没有针对「${displayName}」发出过见解。`
|
||||
|
||||
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||
|
||||
const userPrompt = `触发原因:${triggerDesc}
|
||||
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
|
||||
|
||||
请给出你的见解(≤80字):`
|
||||
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
)
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
|
||||
// 模型主动选择跳过
|
||||
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||
notif.show()
|
||||
} else {
|
||||
insightLog('WARN', '当前系统不支持原生通知')
|
||||
}
|
||||
|
||||
// 渠道二:Telegram Bot 推送(可选)
|
||||
const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean
|
||||
if (telegramEnabled) {
|
||||
const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || ''
|
||||
const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || ''
|
||||
if (telegramToken && telegramChatIds) {
|
||||
const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const telegramText = `【WeFlow】 ${notifTitle}\n\n${insight}`
|
||||
for (const chatId of chatIds) {
|
||||
this.sendTelegram(telegramToken, chatId, telegramText).catch((e) => {
|
||||
insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过')
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Telegram Bot API 发送消息。
|
||||
* 使用 Node 原生 https 模块,无需第三方依赖。
|
||||
*/
|
||||
private sendTelegram(token: string, chatId: string, text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' })
|
||||
const options = {
|
||||
hostname: 'api.telegram.org',
|
||||
port: 443,
|
||||
path: `/bot${token}/sendMessage`,
|
||||
method: 'POST' as const,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString()
|
||||
}
|
||||
}
|
||||
const req = https.request(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(parsed.description || '未知错误'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error(`响应解析失败: ${data.slice(0, 100)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) })
|
||||
req.on('error', reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
@@ -684,10 +694,7 @@ export class KeyService {
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
// --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ---
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
|
||||
@@ -17,21 +17,31 @@ export class KeyServiceLinux {
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.sudo = require('sudo-prompt');
|
||||
this.sudo = require('@vscode/sudo-prompt');
|
||||
} catch (e) {
|
||||
console.error('Failed to load sudo-prompt', e);
|
||||
console.error('Failed to load @vscode/sudo-prompt', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
@@ -361,4 +371,4 @@ export class KeyServiceLinux {
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { join, basename, dirname } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
@@ -373,19 +403,71 @@ export class KeyServiceMac {
|
||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||
const baseDir = dirname(primaryBinaryPath)
|
||||
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||
const unique: string[] = []
|
||||
for (const name of names) {
|
||||
const full = join(baseDir, name)
|
||||
if (!existsSync(full)) continue
|
||||
if (!unique.includes(full)) unique.push(full)
|
||||
}
|
||||
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||
unique.unshift(primaryBinaryPath)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const mode = statSync(p).mode
|
||||
if ((mode & 0o111) !== 0) continue
|
||||
chmodSync(p, mode | 0o111)
|
||||
} catch {
|
||||
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||
const existing = paths.filter(p => existsSync(p))
|
||||
if (existing.length === 0) return
|
||||
|
||||
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||
const scriptLines = [
|
||||
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'with timeout of timeoutSec seconds',
|
||||
'do shell script chmodCmd with administrator privileges',
|
||||
'end timeout'
|
||||
]
|
||||
|
||||
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
timeout: timeoutMs + 10_000
|
||||
})
|
||||
}
|
||||
|
||||
private async getDbKeyByHelperElevated(
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||
: ''
|
||||
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
@@ -721,10 +803,12 @@ export class KeyServiceMac {
|
||||
try {
|
||||
const helperPath = this.getImageScanHelperPath()
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
|
||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||
if (!this._needsElevation) {
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||
if (direct.key) return direct.key
|
||||
if (direct.permissionError) {
|
||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||
@@ -735,7 +819,12 @@ export class KeyServiceMac {
|
||||
|
||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||
if (this._needsElevation) {
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
||||
try {
|
||||
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||
} catch (e: any) {
|
||||
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||
}
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||
if (elevated.key) return elevated.key
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -838,12 +927,19 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private _spawnScanHelper(
|
||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
||||
helperPath: string,
|
||||
pid: number,
|
||||
ciphertextHex: string,
|
||||
elevated: boolean,
|
||||
artifactPaths: string[] = []
|
||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
if (elevated) {
|
||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||
: ''
|
||||
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} else {
|
||||
|
||||
174
electron/services/linuxNotificationService.ts
Normal file
174
electron/services/linuxNotificationService.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
avatarUrl?: string;
|
||||
expireTimeout?: number;
|
||||
}
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
|
||||
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLinuxNotification(
|
||||
data: LinuxNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
notificationCallbacks.push(callback);
|
||||
}
|
||||
|
||||
export function removeNotificationCallback(
|
||||
callback: NotificationCallback,
|
||||
): void {
|
||||
const index = notificationCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
notificationCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLinuxNotificationService(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
} catch {}
|
||||
clearNotificationState(id);
|
||||
}
|
||||
|
||||
// 清理头像文件缓存
|
||||
try {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
@@ -537,6 +538,32 @@ class SnsService {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
throw new Error(result.error || '获取朋友圈发布者失败')
|
||||
}
|
||||
|
||||
const rows = result.timeline
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (username) uniqueUsers.add(username)
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return Array.from(uniqueUsers)
|
||||
}
|
||||
|
||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
@@ -775,14 +802,25 @@ class SnsService {
|
||||
}
|
||||
|
||||
private getSnsCacheDir(): string {
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const snsCacheDir = join(baseDir, 'sns_cache')
|
||||
if (!existsSync(snsCacheDir)) {
|
||||
mkdirSync(snsCacheDir, { recursive: true })
|
||||
}
|
||||
return snsCacheDir
|
||||
}
|
||||
|
||||
private getEmojiCacheDir(): string {
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const emojiDir = join(baseDir, 'Emojis')
|
||||
if (!existsSync(emojiDir)) {
|
||||
mkdirSync(emojiDir, { recursive: true })
|
||||
}
|
||||
return emojiDir
|
||||
}
|
||||
|
||||
private getCacheFilePath(url: string): string {
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||
@@ -794,7 +832,22 @@ class SnsService {
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
|
||||
if (directUsernames.length > 0) {
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
|
||||
try {
|
||||
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
|
||||
if (timelineUsers.length > 0) {
|
||||
return { success: true, usernames: timelineUsers }
|
||||
}
|
||||
} catch {
|
||||
// 忽略回退错误,保持与原行为一致返回空数组
|
||||
}
|
||||
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
@@ -1021,14 +1074,14 @@ class SnsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 补全 DLL 返回的评论中缺失的 refNickname
|
||||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 补全数据服务返回的评论中缺失的 refNickname
|
||||
*数据服务返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||
*/
|
||||
private fixCommentRefs(comments: any[]): any[] {
|
||||
if (!comments || comments.length === 0) return []
|
||||
|
||||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||||
//数据服务现在返回完整的评论数据(含 emojis、refNickname)
|
||||
// 此处做最终的格式化和兜底补全
|
||||
const idToNickname = new Map<string, string>()
|
||||
comments.forEach((c, idx) => {
|
||||
@@ -1099,14 +1152,14 @@ class SnsService {
|
||||
} : undefined
|
||||
}))
|
||||
|
||||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
//数据服务已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
const dllComments: any[] = post.comments || []
|
||||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||
|
||||
let finalComments: any[]
|
||||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||
// DLL 数据完整,直接使用
|
||||
//数据服务数据完整,直接使用
|
||||
finalComments = this.fixCommentRefs(dllComments)
|
||||
} else if (rawXml) {
|
||||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||
@@ -1199,7 +1252,7 @@ class SnsService {
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||
return this.fetchAndDecryptImage(url, key)
|
||||
}
|
||||
|
||||
@@ -1791,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const isVideo = isVideoUrl(url)
|
||||
const cachePath = this.getCacheFilePath(url)
|
||||
|
||||
// 1. 尝试从磁盘缓存读取
|
||||
// 1. 优先尝试从当前缓存目录读取
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||
@@ -2252,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
|
||||
const fs = require('fs')
|
||||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||||
const emojiDir = this.getEmojiCacheDir()
|
||||
|
||||
// 检查本地缓存
|
||||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
import { pathToFileURL } from 'url'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
@@ -22,15 +25,50 @@ interface VideoIndexEntry {
|
||||
thumbPath?: string
|
||||
}
|
||||
|
||||
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
if (typeof ffmpegStatic === 'string') {
|
||||
let fixedPath = ffmpegStatic
|
||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
if (existsSync(fixedPath)) return fixedPath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(devPath)) return devPath
|
||||
|
||||
if (app.isPackaged) {
|
||||
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(packedPath)) return packedPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||
private pendingPosterExtract = new Map<string, Promise<string | null>>()
|
||||
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private posterExtractRunning = 0
|
||||
private posterExtractQueue: Array<() => void> = []
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
||||
private readonly maxPosterExtractConcurrency = 1
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
@@ -256,12 +294,10 @@ class VideoService {
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||
try {
|
||||
if (!filePath || !existsSync(filePath)) return undefined
|
||||
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
@@ -355,7 +391,12 @@ class VideoService {
|
||||
return index
|
||||
}
|
||||
|
||||
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||
private getVideoInfoFromIndex(
|
||||
index: Map<string, VideoIndexEntry>,
|
||||
md5: string,
|
||||
includePoster = true,
|
||||
posterFormat: PosterFormat = 'dataUrl'
|
||||
): VideoInfo | null {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
@@ -379,8 +420,8 @@ class VideoService {
|
||||
}
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -388,7 +429,12 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||
private fallbackScanVideo(
|
||||
videoBaseDir: string,
|
||||
realVideoMd5: string,
|
||||
includePoster = true,
|
||||
posterFormat: PosterFormat = 'dataUrl'
|
||||
): VideoInfo | null {
|
||||
try {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
@@ -416,8 +462,8 @@ class VideoService {
|
||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
|
||||
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
@@ -427,14 +473,165 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
if (staticPath) return staticPath
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
|
||||
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.posterExtractQueue.push(resolve)
|
||||
})
|
||||
}
|
||||
this.posterExtractRunning += 1
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
|
||||
const next = this.posterExtractQueue.shift()
|
||||
if (next) next()
|
||||
}
|
||||
}
|
||||
|
||||
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
|
||||
const normalizedPath = String(videoPath || '').trim()
|
||||
if (!normalizedPath || !existsSync(normalizedPath)) return null
|
||||
|
||||
const cacheKey = `${normalizedPath}|format=${posterFormat}`
|
||||
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const pending = this.pendingPosterExtract.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
|
||||
try {
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
} catch {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
|
||||
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
|
||||
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
|
||||
resolve(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
|
||||
const ffmpegPath = this.getFfmpegPath()
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error', '-y',
|
||||
'-ss', '0',
|
||||
'-i', normalizedPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '3',
|
||||
outputPath
|
||||
]
|
||||
|
||||
const errChunks: Buffer[] = []
|
||||
let done = false
|
||||
const finish = (value: string | null) => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (posterFormat === 'dataUrl') {
|
||||
try {
|
||||
if (existsSync(outputPath)) unlinkSync(outputPath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const proc = spawn(ffmpegPath, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
||||
finish(null)
|
||||
}, 12000)
|
||||
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
proc.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
finish(null)
|
||||
})
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0 || !existsSync(outputPath)) {
|
||||
if (errChunks.length > 0) {
|
||||
this.log('extractFirstFrameDataUrl failed', {
|
||||
videoPath: normalizedPath,
|
||||
error: Buffer.concat(errChunks).toString().slice(0, 240)
|
||||
})
|
||||
}
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const jpgBuf = readFileSync(outputPath)
|
||||
if (!jpgBuf.length) {
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
if (posterFormat === 'fileUrl') {
|
||||
finish(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
this.pendingPosterExtract.set(cacheKey, task)
|
||||
try {
|
||||
const result = await task
|
||||
this.writeTimedCache(
|
||||
this.extractedPosterCache,
|
||||
cacheKey,
|
||||
result,
|
||||
this.extractedPosterCacheTtlMs,
|
||||
this.maxCacheEntries
|
||||
)
|
||||
return result
|
||||
} finally {
|
||||
this.pendingPosterExtract.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||
if (!includePoster) return info
|
||||
if (!info.exists || !info.videoUrl) return info
|
||||
if (info.coverUrl || info.thumbUrl) return info
|
||||
|
||||
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
|
||||
if (!extracted) return info
|
||||
return {
|
||||
...info,
|
||||
coverUrl: extracted,
|
||||
thumbUrl: extracted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
|
||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||
const includePoster = options?.includePoster !== false
|
||||
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
@@ -446,7 +643,7 @@ class VideoService {
|
||||
}
|
||||
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
|
||||
|
||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||
if (cachedInfo) return cachedInfo
|
||||
@@ -465,16 +662,18 @@ class VideoService {
|
||||
}
|
||||
|
||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
|
||||
if (indexed) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return indexed
|
||||
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return withPoster
|
||||
}
|
||||
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
|
||||
if (fallback) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return fallback
|
||||
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return withPoster
|
||||
}
|
||||
|
||||
const miss = { exists: false }
|
||||
|
||||
@@ -76,7 +76,7 @@ export class VoiceTranscribeService {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖
|
||||
// Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖
|
||||
const existing = env['PATH'] || ''
|
||||
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ export class WcdbService {
|
||||
// Worker 退出,需要 reject 所有 pending promises
|
||||
if (code !== 0) {
|
||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(errorMsg))
|
||||
}
|
||||
@@ -268,6 +268,37 @@ export class WcdbService {
|
||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||
}
|
||||
|
||||
async getMediaStream(options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
items?: Array<{
|
||||
sessionId: string
|
||||
sessionDisplayName?: string
|
||||
mediaType: 'image' | 'video'
|
||||
localId: number
|
||||
serverId?: string
|
||||
createTime: number
|
||||
localType: number
|
||||
senderUsername?: string
|
||||
isSend?: number | null
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
videoMd5?: string
|
||||
content?: string
|
||||
}>
|
||||
hasMore?: boolean
|
||||
nextOffset?: number
|
||||
error?: string
|
||||
}> {
|
||||
return this.callWorker('getMediaStream', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -417,6 +448,19 @@ export class WcdbService {
|
||||
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getMyFootprintStats(options: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
myWxid?: string
|
||||
privateSessionIds?: string[]
|
||||
groupSessionIds?: string[]
|
||||
mentionLimit?: number
|
||||
privateLimit?: number
|
||||
mentionMode?: 'text_at_me' | string
|
||||
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMyFootprintStats', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开消息游标
|
||||
*/
|
||||
@@ -445,6 +489,44 @@ export class WcdbService {
|
||||
return this.callWorker('closeMessageCursor', { cursor })
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Lab: 获取多数据源 Schema 摘要
|
||||
*/
|
||||
async sqlLabGetSchema(payload?: { sessionId?: string }): Promise<{
|
||||
success: boolean
|
||||
schema?: {
|
||||
generatedAt: number
|
||||
sources: Array<{
|
||||
kind: 'message' | 'contact' | 'biz'
|
||||
path: string | null
|
||||
label: string
|
||||
tables: Array<{ name: string; columns: string[] }>
|
||||
}>
|
||||
}
|
||||
schemaText?: string
|
||||
error?: string
|
||||
}> {
|
||||
return this.callWorker('sqlLabGetSchema', payload || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Lab: 执行只读 SQL
|
||||
*/
|
||||
async sqlLabExecuteReadonly(payload: {
|
||||
kind: 'message' | 'contact' | 'biz'
|
||||
path?: string | null
|
||||
sql: string
|
||||
limit?: number
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
rows?: any[]
|
||||
columns?: string[]
|
||||
total?: number
|
||||
error?: string
|
||||
}> {
|
||||
return this.callWorker('sqlLabExecuteReadonly', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
@@ -467,7 +549,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表情包释义(严格 DLL 接口)
|
||||
* 获取表情包释义(严格数据服务接口)
|
||||
*/
|
||||
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||
@@ -498,6 +580,42 @@ export class WcdbService {
|
||||
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async aiQuerySessionCandidates(options: {
|
||||
keyword: string
|
||||
limit?: number
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('aiQuerySessionCandidates', { options })
|
||||
}
|
||||
|
||||
async aiQueryTimeline(options: {
|
||||
sessionId?: string
|
||||
keyword: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('aiQueryTimeline', { options })
|
||||
}
|
||||
|
||||
async aiQueryTopicStats(options: {
|
||||
sessionIds: string[]
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('aiQueryTopicStats', { options })
|
||||
}
|
||||
|
||||
async aiQuerySourceRefs(options: {
|
||||
sessionIds: string[]
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('aiQuerySourceRefs', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音数据
|
||||
*/
|
||||
@@ -561,6 +679,24 @@ export class WcdbService {
|
||||
return this.callWorker('getSnsExportStats', { myWxid })
|
||||
}
|
||||
|
||||
async checkMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async installMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async uninstallMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
@@ -590,7 +726,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DLL 内部日志
|
||||
* 获取数据服务内部日志
|
||||
*/
|
||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||
return this.callWorker('getLogs')
|
||||
|
||||
@@ -80,6 +80,9 @@ if (parentPort) {
|
||||
case 'getMessagesByType':
|
||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMediaStream':
|
||||
result = await core.getMediaStream(payload.options)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -155,6 +158,9 @@ if (parentPort) {
|
||||
case 'getGroupStats':
|
||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getMyFootprintStats':
|
||||
result = await core.getMyFootprintStats(payload.options || {})
|
||||
break
|
||||
case 'openMessageCursor':
|
||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -167,6 +173,12 @@ if (parentPort) {
|
||||
case 'closeMessageCursor':
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'sqlLabGetSchema':
|
||||
result = await core.sqlLabGetSchema(payload)
|
||||
break
|
||||
case 'sqlLabExecuteReadonly':
|
||||
result = await core.sqlLabExecuteReadonly(payload)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||
break
|
||||
@@ -191,6 +203,18 @@ if (parentPort) {
|
||||
case 'searchMessages':
|
||||
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'aiQuerySessionCandidates':
|
||||
result = await core.aiQuerySessionCandidates(payload.options || {})
|
||||
break
|
||||
case 'aiQueryTimeline':
|
||||
result = await core.aiQueryTimeline(payload.options || {})
|
||||
break
|
||||
case 'aiQueryTopicStats':
|
||||
result = await core.aiQueryTopicStats(payload.options || {})
|
||||
break
|
||||
case 'aiQuerySourceRefs':
|
||||
result = await core.aiQuerySourceRefs(payload.options || {})
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
@@ -230,6 +254,15 @@ if (parentPort) {
|
||||
case 'getSnsExportStats':
|
||||
result = await core.getSnsExportStats(payload.myWxid)
|
||||
break
|
||||
case 'checkMessageAntiRevokeTriggers':
|
||||
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installMessageAntiRevokeTriggers':
|
||||
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'uninstallMessageAntiRevokeTriggers':
|
||||
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
|
||||
@@ -1,224 +1,343 @@
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from '../services/config'
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
// Linux D-Bus通知服务
|
||||
const isLinux = process.platform === "linux";
|
||||
let linuxNotificationService:
|
||||
| typeof import("../services/linuxNotificationService")
|
||||
| null = null;
|
||||
|
||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||
|
||||
export function setNotificationNavigateHandler(
|
||||
callback: (sessionId: string) => void,
|
||||
) {
|
||||
onNotificationNavigate = callback;
|
||||
}
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null;
|
||||
let closeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (isLinux && linuxNotificationService) {
|
||||
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||
});
|
||||
linuxNotificationService = null;
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
const win = notificationWindow;
|
||||
notificationWindow = null;
|
||||
|
||||
try {
|
||||
win.destroy();
|
||||
} catch (error) {
|
||||
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||
const iconPath = isDev
|
||||
? join(__dirname, "../../public/icon.ico")
|
||||
: join(process.resourcesPath, "icon.ico");
|
||||
|
||||
console.log('[NotificationWindow] Creating window...')
|
||||
const width = 344
|
||||
const height = 114
|
||||
console.log("[NotificationWindow] Creating window...");
|
||||
const width = 344;
|
||||
const height = 114;
|
||||
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
}
|
||||
})
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
},
|
||||
});
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||
|
||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||
notificationWindow.loadURL(loadUrl)
|
||||
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||
notificationWindow.loadURL(loadUrl);
|
||||
|
||||
notificationWindow.on('closed', () => {
|
||||
notificationWindow = null
|
||||
})
|
||||
notificationWindow.on("closed", () => {
|
||||
notificationWindow = null;
|
||||
});
|
||||
|
||||
return notificationWindow
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance()
|
||||
const enabled = await config.get('notificationEnabled')
|
||||
if (enabled === false) return // 默认为 true
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance();
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||
const filterList = config.get('notificationFilterList') || []
|
||||
const sessionId = data.sessionId
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
||||
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||
|
||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId)
|
||||
if (filterMode === 'whitelist' && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (filterMode === 'blacklist' && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (!isSystemNotification && filterMode !== "all") {
|
||||
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||
return;
|
||||
}
|
||||
|
||||
let win = notificationWindow
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow()
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!win) return
|
||||
// Linux 使用 D-Bus 通知
|
||||
if (isLinux) {
|
||||
await showLinuxNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once('ready-to-show', () => {
|
||||
showAndSend(win!, data)
|
||||
})
|
||||
} else {
|
||||
showAndSend(win, data)
|
||||
}
|
||||
let win = notificationWindow;
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow();
|
||||
}
|
||||
|
||||
if (!win) return;
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once("ready-to-show", () => {
|
||||
showAndSend(win!, data);
|
||||
});
|
||||
} else {
|
||||
showAndSend(win, data);
|
||||
}
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null
|
||||
// 显示Linux通知
|
||||
async function showLinuxNotification(data: any) {
|
||||
if (!linuxNotificationService) {
|
||||
try {
|
||||
linuxNotificationService =
|
||||
await import("../services/linuxNotificationService");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to load Linux notification service:",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||
|
||||
const notificationData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
avatarUrl: data.avatarUrl,
|
||||
sessionId: data.sessionId,
|
||||
expireTimeout: 5000,
|
||||
};
|
||||
|
||||
showNotification(notificationData);
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null;
|
||||
|
||||
async function showAndSend(win: BrowserWindow, data: any) {
|
||||
lastNotificationData = data
|
||||
const config = ConfigService.getInstance()
|
||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||
lastNotificationData = data;
|
||||
const config = ConfigService.getInstance();
|
||||
const position = (await config.get("notificationPosition")) || "top-right";
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } =
|
||||
screen.getPrimaryDisplay().workAreaSize;
|
||||
const winWidth = position === "top-center" ? 280 : 344;
|
||||
const winHeight = 114;
|
||||
const padding = 20;
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
case 'top-left':
|
||||
x = padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-left':
|
||||
x = padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
switch (position) {
|
||||
case "top-center":
|
||||
x = (screenWidth - winWidth) / 2;
|
||||
y = padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.setSize(winWidth, winHeight); // 确保尺寸
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.showInactive(); // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||
|
||||
win.webContents.send("notification:show", { ...data, position });
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
// 注册通知处理
|
||||
export async function registerNotificationHandlers() {
|
||||
// Linux: 初始化D-Bus服务
|
||||
if (isLinux) {
|
||||
try {
|
||||
const linuxNotificationModule =
|
||||
await import("../services/linuxNotificationService");
|
||||
linuxNotificationService = linuxNotificationModule;
|
||||
|
||||
// 初始化服务
|
||||
await linuxNotificationModule.initLinuxNotificationService();
|
||||
|
||||
// 在Linux上注册通知点击回调
|
||||
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||
sessionId,
|
||||
);
|
||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||
if (onNotificationNavigate) {
|
||||
onNotificationNavigate(sessionId);
|
||||
} else {
|
||||
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||
console.warn(
|
||||
"[NotificationWindow] onNotificationNavigate not set yet",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification service initialized",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to initialize Linux notification service:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y))
|
||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||
ipcMain.handle("notification:show", (_, data) => {
|
||||
showNotification(data);
|
||||
});
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false)
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
ipcMain.handle("notification:close", () => {
|
||||
if (isLinux && linuxNotificationService) {
|
||||
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide();
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.send('notification:show', { ...data, position })
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on("notification:ready", (event) => {
|
||||
if (isLinux) {
|
||||
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||
return;
|
||||
}
|
||||
console.log("[NotificationWindow] Renderer ready, checking cached data");
|
||||
if (
|
||||
lastNotificationData &&
|
||||
notificationWindow &&
|
||||
!notificationWindow.isDestroyed()
|
||||
) {
|
||||
console.log("[NotificationWindow] Re-sending cached data");
|
||||
notificationWindow.webContents.send(
|
||||
"notification:show",
|
||||
lastNotificationData,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers() {
|
||||
ipcMain.handle('notification:show', (_, data) => {
|
||||
showNotification(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('notification:close', () => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide()
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on('notification:ready', (event) => {
|
||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
console.log('[NotificationWindow] Re-sending cached data')
|
||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds()
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||
}
|
||||
})
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||
if (isLinux) {
|
||||
// Linux 通知通过D-Bus自动调整大小
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds();
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||
}
|
||||
});
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
}
|
||||
|
||||
4137
package-lock.json
generated
4137
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
91
package.json
91
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "4.2.0",
|
||||
"version": "4.3.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": {
|
||||
@@ -23,9 +23,10 @@
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.1",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
@@ -34,16 +35,15 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -52,15 +52,29 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^25.1.8",
|
||||
"sass": "^1.83.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"tar": ">=6.2.1",
|
||||
"minimatch": ">=3.1.2",
|
||||
"rollup": ">=4.0.0",
|
||||
"immutable": ">=4.0.0",
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0",
|
||||
"ajv-keywords@3>ajv": "^6.12.6",
|
||||
"@develar/schema-utils>ajv": "^6.12.6"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
@@ -84,13 +98,31 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
@@ -103,7 +135,7 @@
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/linux/install.sh",
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
@@ -158,24 +190,11 @@
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
"tar": "^7.5.13",
|
||||
"immutable": "^5.1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/icon.ico
BIN
public/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
Binary file not shown.
0
resources/xkey_helper_linux → resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
0
resources/xkey_helper_linux → resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
0
resources/xkey_helper → resources/key/macos/universal/xkey_helper
Executable file → Normal file
0
resources/xkey_helper → resources/key/macos/universal/xkey_helper
Executable file → Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
Executable file → Normal file
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
Executable file → Normal file
Binary file not shown.
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
131
src/App.tsx
131
src/App.tsx
@@ -6,6 +6,7 @@ import RouteGuard from './components/RouteGuard'
|
||||
import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AiAnalysisPage from './pages/AiAnalysisPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
@@ -17,10 +18,13 @@ import AgreementPage from './pages/AgreementPage'
|
||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import MyFootprintPage from './pages/MyFootprintPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
|
||||
@@ -103,44 +107,7 @@ function App() {
|
||||
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
|
||||
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkWaylandStatus = async () => {
|
||||
try {
|
||||
// 防止在非客户端环境报错,先检查 API 是否存在
|
||||
if (!window.electronAPI?.app?.checkWayland) return
|
||||
|
||||
// 通过 configService 检查是否已经弹过窗
|
||||
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
|
||||
|
||||
if (!hasWarned) {
|
||||
const isWayland = await window.electronAPI.app.checkWayland()
|
||||
if (isWayland) {
|
||||
setShowWaylandWarning(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查 Wayland 状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在协议同意之后并且已经进入主应用流程才检查
|
||||
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
|
||||
checkWaylandStatus()
|
||||
}
|
||||
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
|
||||
|
||||
const handleDismissWaylandWarning = async () => {
|
||||
try {
|
||||
// 记录到本地配置中,下次不再提示
|
||||
await window.electronAPI.config.set('waylandWarningShown', true)
|
||||
} catch (e) {
|
||||
console.error('保存 Wayland 提示状态失败:', e)
|
||||
}
|
||||
setShowWaylandWarning(false)
|
||||
}
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/settings') {
|
||||
@@ -252,6 +219,7 @@ function App() {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
setAnalyticsConsent(consent)
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
@@ -266,18 +234,21 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
// 初始化数据收集(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
if (analyticsConsent === true) {
|
||||
cloudControl.initCloudControl()
|
||||
}
|
||||
}, [analyticsConsent])
|
||||
|
||||
// 记录页面访问
|
||||
// 记录页面访问(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
if (analyticsConsent !== true) return
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
}, [location.pathname, analyticsConsent])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
@@ -296,6 +267,7 @@ function App() {
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
@@ -312,10 +284,14 @@ function App() {
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
window.electronAPI.app.getVersion().then((currentVersion: string) => {
|
||||
const isMandatory = !!(info.minimumVersion && currentVersion &&
|
||||
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
|
||||
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
@@ -327,6 +303,21 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 监听通知点击导航事件
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return
|
||||
|
||||
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
// 导航到聊天页面,通过URL参数让ChatPage接收sessionId
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
@@ -419,7 +410,7 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
@@ -580,9 +571,13 @@ function App() {
|
||||
<div className="agreement-notice">
|
||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||
<span className="agreement-notice-link">
|
||||
我们唯一的官方网站:
|
||||
官方网站:
|
||||
<a href="https://weflow.top" target="_blank" rel="noreferrer">
|
||||
https://weflow.top
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||
https://github.com/hicccc77/WeFlow
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -597,7 +592,7 @@ function App() {
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||
|
||||
<h4>4. 隐私保护</h4>
|
||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
@@ -654,41 +649,15 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWaylandWarning && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>环境兼容性提示 (Wayland)</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||
<br />
|
||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
updateInfo={updateInfo}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
|
||||
onUpdate={handleUpdateNow}
|
||||
onIgnore={handleIgnoreUpdate}
|
||||
isDownloading={isDownloading}
|
||||
isMandatory={!!(updateInfo as any)?.isMandatory}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
@@ -711,6 +680,7 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/ai-analysis" element={<AiAnalysisPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
@@ -722,10 +692,13 @@ function App() {
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -54,10 +54,11 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: 1px solid var(--border-color);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -288,4 +289,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,20 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [internalStart, setInternalStart] = useState(startDate)
|
||||
const [internalEnd, setInternalEnd] = useState(endDate)
|
||||
|
||||
useEffect(() => {
|
||||
setInternalStart(startDate)
|
||||
setInternalEnd(endDate)
|
||||
}, [startDate, endDate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectingStart(true)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -63,8 +77,10 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - days)
|
||||
onStartDateChange(start.toISOString().split('T')[0])
|
||||
onEndDateChange(end.toISOString().split('T')[0])
|
||||
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
|
||||
const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||
onStartDateChange(startStr)
|
||||
onEndDateChange(endStr)
|
||||
}
|
||||
setIsOpen(false)
|
||||
setTimeout(() => onRangeComplete?.(), 0)
|
||||
@@ -89,38 +105,46 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
|
||||
if (selectingStart) {
|
||||
onStartDateChange(dateStr)
|
||||
if (endDate && dateStr > endDate) {
|
||||
onEndDateChange('')
|
||||
setInternalStart(dateStr)
|
||||
if (internalEnd && dateStr > internalEnd) {
|
||||
setInternalEnd('')
|
||||
}
|
||||
setSelectingStart(false)
|
||||
} else {
|
||||
if (dateStr < startDate) {
|
||||
onStartDateChange(dateStr)
|
||||
onEndDateChange(startDate)
|
||||
} else {
|
||||
onEndDateChange(dateStr)
|
||||
let finalStart = internalStart
|
||||
let finalEnd = dateStr
|
||||
|
||||
if (dateStr < internalStart) {
|
||||
finalStart = dateStr
|
||||
finalEnd = internalStart
|
||||
}
|
||||
|
||||
setInternalStart(finalStart)
|
||||
setInternalEnd(finalEnd)
|
||||
|
||||
setSelectingStart(true)
|
||||
setIsOpen(false)
|
||||
|
||||
onStartDateChange(finalStart)
|
||||
onEndDateChange(finalEnd)
|
||||
setTimeout(() => onRangeComplete?.(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const isInRange = (day: number) => {
|
||||
if (!startDate || !endDate) return false
|
||||
if (!internalStart || !internalEnd) return false
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr >= startDate && dateStr <= endDate
|
||||
return dateStr >= internalStart && dateStr <= internalEnd
|
||||
}
|
||||
|
||||
const isStartDate = (day: number) => {
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr === startDate
|
||||
return dateStr === internalStart
|
||||
}
|
||||
|
||||
const isEndDate = (day: number) => {
|
||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return dateStr === endDate
|
||||
return dateStr === internalEnd
|
||||
}
|
||||
|
||||
const isToday = (day: number) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
|
||||
format?: string
|
||||
avatars?: boolean
|
||||
dateRange?: ExportDateRangeSelection
|
||||
fileNamingMode?: configService.ExportFileNamingMode
|
||||
media?: configService.ExportDefaultMediaConfig
|
||||
voiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
] as const
|
||||
|
||||
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
|
||||
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
|
||||
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
|
||||
]
|
||||
|
||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||
|
||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||
@@ -56,17 +62,21 @@ export function ExportDefaultsSettingsForm({
|
||||
layout = 'stacked'
|
||||
}: ExportDefaultsSettingsFormProps) {
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
@@ -75,10 +85,11 @@ export function ExportDefaultsSettingsForm({
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultFileNamingMode(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
@@ -90,11 +101,13 @@ export function ExportDefaultsSettingsForm({
|
||||
setExportDefaultFormat(savedFormat || 'excel')
|
||||
setExportDefaultAvatars(savedAvatars ?? true)
|
||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setExportDefaultMedia(savedMedia ?? {
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
@@ -112,15 +125,19 @@ export function ExportDefaultsSettingsForm({
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect])
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
|
||||
|
||||
const notify = (text: string, success = true) => {
|
||||
onNotify?.(text, success)
|
||||
@@ -222,6 +239,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -245,6 +263,50 @@ export function ExportDefaultsSettingsForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>导出文件命名方式</label>
|
||||
<span className="form-hint">控制导出文件名是否包含时间范围</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFileNamingModeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>Excel 列显示</label>
|
||||
@@ -257,6 +319,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
@@ -292,7 +355,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<div className="form-group media-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出媒体内容</label>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="media-default-grid">
|
||||
@@ -352,6 +415,20 @@ export function ExportDefaultsSettingsForm({
|
||||
/>
|
||||
表情包
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.files}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, files: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -172,6 +174,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.year-btn {
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,4 +401,4 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
messageDates,
|
||||
loadingDates = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showYearMonthPicker ? (
|
||||
{viewMode === 'month' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||
{monthNames.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'year' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn {
|
||||
@@ -83,6 +97,37 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-grid,
|
||||
.jump-date-popover .year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
min-height: 256px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell,
|
||||
.jump-date-popover .year-cell {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell:hover,
|
||||
.jump-date-popover .year-cell:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell.active,
|
||||
.jump-date-popover .year-cell.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell {
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const normalized = new Date(currentDate)
|
||||
setCalendarDate(normalized)
|
||||
setSelectedDate(normalized)
|
||||
setViewMode('day')
|
||||
setYearPageStart(getYearPageStart(normalized.getFullYear()))
|
||||
}, [isOpen, currentDate])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
@@ -154,36 +213,74 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
{viewMode === 'day' && (
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<div className="month-grid">
|
||||
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, index) => {
|
||||
if (day === null) return <div key={index} className="day-cell empty" />
|
||||
const dateKey = toDateKey(day)
|
||||
const hasMessageOnDay = hasMessage(day)
|
||||
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||
const showCount = count > 0
|
||||
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={getDayClassName(day)}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||
type="button"
|
||||
>
|
||||
<span className="day-number">{day}</span>
|
||||
{showCount && <span className="day-count">{count}</span>}
|
||||
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
|
||||
{viewMode === 'year' && (
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints, Sparkles } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
@@ -409,6 +409,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* AI分析 */}
|
||||
<NavLink
|
||||
to="/ai-analysis"
|
||||
className={`nav-item ${isActive('/ai-analysis') ? 'active' : ''}`}
|
||||
title={collapsed ? 'AI分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Sparkles size={20} /></span>
|
||||
<span className="nav-label">AI分析</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
@@ -429,6 +439,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 资源浏览 */}
|
||||
<NavLink
|
||||
to="/resources"
|
||||
className={`nav-item ${isActive('/resources') ? 'active' : ''}`}
|
||||
title={collapsed ? '资源浏览' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FolderClosed size={20} /></span>
|
||||
<span className="nav-label">资源浏览</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 聊天分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
@@ -449,6 +469,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">年度报告</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 我的足迹 */}
|
||||
<NavLink
|
||||
to="/footprint"
|
||||
className={`nav-item ${isActive('/footprint') ? 'active' : ''}`}
|
||||
title={collapsed ? '我的足迹' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Footprints size={20} /></span>
|
||||
<span className="nav-label">我的足迹</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 导出 */}
|
||||
<NavLink
|
||||
to="/export"
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SnsFilterPanelProps {
|
||||
activeContactUsername?: string
|
||||
onOpenContactTimeline: (contact: Contact) => void
|
||||
onToggleContactSelected: (contact: Contact) => void
|
||||
onToggleFilteredContacts: (usernames: string[], shouldSelect: boolean) => void
|
||||
onClearSelectedContacts: () => void
|
||||
onExportSelectedContacts: () => void
|
||||
}
|
||||
@@ -46,6 +47,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
activeContactUsername,
|
||||
onOpenContactTimeline,
|
||||
onToggleContactSelected,
|
||||
onToggleFilteredContacts,
|
||||
onClearSelectedContacts,
|
||||
onExportSelectedContacts
|
||||
}) => {
|
||||
@@ -57,6 +59,16 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
() => new Set(selectedContactUsernames),
|
||||
[selectedContactUsernames]
|
||||
)
|
||||
const filteredContactUsernames = React.useMemo(
|
||||
() => filteredContacts.map((contact) => contact.username),
|
||||
[filteredContacts]
|
||||
)
|
||||
const selectedFilteredCount = React.useMemo(
|
||||
() => filteredContactUsernames.filter((username) => selectedContactLookup.has(username)).length,
|
||||
[filteredContactUsernames, selectedContactLookup]
|
||||
)
|
||||
const hasFilteredContacts = filteredContactUsernames.length > 0
|
||||
const allFilteredSelected = hasFilteredContacts && selectedFilteredCount === filteredContactUsernames.length
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
@@ -128,6 +140,20 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="contact-selection-toolbar">
|
||||
<span className="contact-selection-summary">
|
||||
当前 {filteredContactUsernames.length} 人,已选 {selectedFilteredCount} 人
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-selection-toggle${allFilteredSelected ? ' active' : ''}`}
|
||||
onClick={() => onToggleFilteredContacts(filteredContactUsernames, !allFilteredSelected)}
|
||||
disabled={!hasFilteredContacts}
|
||||
>
|
||||
{allFilteredSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||
<div className="contact-count-progress">
|
||||
{contactsCountProgress.running
|
||||
|
||||
@@ -282,4 +282,13 @@
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.mandatory-tip {
|
||||
color: #e53e3e;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin: 0 0 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(229, 62, 62, 0.08);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user