Compare commits

...

355 Commits

Author SHA1 Message Date
cc
599fd1af26 feat: ai功能的初次提交 2026-04-11 23:12:03 +08:00
cc
b9af7ffc8c 一些更新 2026-04-11 19:52:40 +08:00
cc
726edfa850 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-11 17:14:41 +08:00
cc
ff33242887 实现 #706 2026-04-11 17:14:34 +08:00
cc
102eb14b0b Merge pull request #704 from Tosd0/main
fix: 非预期白名单通知行为
2026-04-10 21:08:13 +08:00
cc
e57b9d07f1 Merge pull request #703 from Jasonzhu1207/main
fix:修改了几处中文乱码
2026-04-10 21:07:41 +08:00
Tosd0
3be90d00e5 fix(notification): 系统通知豁免会话白/黑名单过滤 2026-04-10 21:00:28 +08:00
Tosd0
efb5cd3586 fix(notification): 修复白名单为空时过滤器完全失效的问题 2026-04-10 21:00:22 +08:00
Jason
86b1043134 Merge pull request #15 from Jasonzhu1207/chore/sync-upstream-main-20260410
chore: sync upstream main into fork main
2026-04-10 20:51:48 +08:00
Jason
36bed846b2 chore: merge upstream main into fork main 2026-04-10 20:45:04 +08:00
cc
9d3d38fa7e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-10 20:34:19 +08:00
cc
ddf6b63aec 更新描述文案 2026-04-10 20:34:16 +08:00
Jason
079779c2c6 Merge pull request #14 from Jasonzhu1207/fix/chinese-garbled-text
fix: clean up garbled Chinese text
2026-04-10 20:23:30 +08:00
Jason
afa8bb5fe0 fix: clean up garbled Chinese text 2026-04-10 20:13:46 +08:00
cc
127668ae22 Merge pull request #702 from hicccc77/main
合并
2026-04-10 20:13:18 +08:00
cc
b00264d060 Merge pull request #701 from hicccc77/hicccc77-patch-1
Update README.md
2026-04-10 20:12:53 +08:00
cc
2e135587d4 Update README.md 2026-04-10 20:12:42 +08:00
cc
571bffa923 Merge pull request #700 from hicccc77/dev
更新资源
2026-04-10 20:01:59 +08:00
cc
bc355d43a0 更新资源 2026-04-10 20:01:36 +08:00
cc
e2a207be92 Merge pull request #699 from hicccc77/dev
Dev
2026-04-10 19:46:33 +08:00
cc
397cc888db 尝试修复工作流;修复mac上权限异常的问题 2026-04-10 19:46:11 +08:00
cc
22a2616534 修复密钥问题 2026-04-10 19:23:32 +08:00
cc
d6c9a10766 优化表述与提示;导出文件命名格式优化;启用进程优化 2026-04-09 21:13:13 +08:00
cc
657e8015b2 Merge pull request #680 from hicccc77/dev
Dev
2026-04-09 18:19:07 +08:00
cc
fc3612abb2 Merge branch 'main' into dev 2026-04-09 18:18:58 +08:00
cc
8d79a82ac2 Merge pull request #681 from xunchahaha/main
修复工作流
2026-04-09 18:18:44 +08:00
cc
234cf690f0 Merge pull request #683 from H3CoF6/dev
删除wayland的检测和警告相关代码
2026-04-09 18:18:33 +08:00
H3CoF6
d768c8d08c fix: 删除wayland的检测和警告相关代码
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:22:13 +08:00
xuncha
e98e9cb7d9 修复工作流 2026-04-09 11:47:02 +08:00
cc
8e8f1b3d22 删除见解多余日志 2026-04-08 19:50:25 +08:00
cc
5b6117ec28 修复见解意外启动的问题 2026-04-08 19:32:44 +08:00
cc
33188485b7 修复一些乱码问题 2026-04-08 19:26:48 +08:00
cc
08bd5e5435 Merge branch 'dev' into dev 2026-04-08 19:21:42 +08:00
cc
714827a36d 修复了一些问题 2026-04-08 19:20:30 +08:00
fatfathao
7a51d8cf64 fix: 将通知头像存储在缓存文件中,通过LRU缓存维护头像缓存数量,点击通知后可以跳转到对应的会话窗口(fixes #654) 2026-04-08 13:11:27 +08:00
cc
902d2c9c74 Merge pull request #666 from xunchahaha/dev
Dev
2026-04-07 23:34:44 +08:00
Jason
d96000f0d9 Merge branch 'hicccc77:main' into main 2026-04-07 23:05:17 +08:00
xuncha
dcad30bc39 x修复工作流 2026-04-07 22:58:41 +08:00
xuncha
73ee524d1f Merge branch 'dev' into dev 2026-04-07 22:49:10 +08:00
xuncha
4af8334f50 修复图片解密 2026-04-07 22:45:15 +08:00
cc
43fed79204 Merge pull request #653 from Jasonzhu1207/feature/ai-insight
Feature:增加AI见解功能
2026-04-07 22:21:42 +08:00
cc
b356814ebb 规范化资源文件;修复消息气泡宽度异常的问题;优化资源管理页面性能 2026-04-07 20:53:45 +08:00
cc
0acad9927a 重新修复 #654 所提到的问题 2026-04-07 20:14:23 +08:00
cc
5bc46fadfc Merge pull request #665 from hicccc77/main
Dev
2026-04-07 19:49:39 +08:00
cc
3090306394 Merge branch 'dev' into main 2026-04-07 19:49:31 +08:00
cc
ec95a16c7a Merge pull request #626 from hicccc77/dependabot/npm_and_yarn/dev/vite-plugin-electron-0.29.1
chore(deps-dev): bump vite-plugin-electron from 0.28.8 to 0.29.1
2026-04-07 19:46:44 +08:00
cc
45d3f735a9 Merge pull request #627 from hicccc77/dependabot/npm_and_yarn/dev/sherpa-onnx-node-1.12.35
chore(deps): bump sherpa-onnx-node from 1.12.34 to 1.12.35
2026-04-07 19:46:35 +08:00
cc
0734b64cc8 Merge pull request #628 from hicccc77/dependabot/npm_and_yarn/dev/sass-1.99.0
chore(deps-dev): bump sass from 1.98.0 to 1.99.0
2026-04-07 19:46:22 +08:00
cc
70ad21cb46 Merge pull request #657 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-c4bc6a0a9e
chore(deps-dev): bump vite from 7.3.1 to 7.3.2 in the npm_and_yarn group across 1 directory
2026-04-07 19:45:51 +08:00
xuncha
9181490d0f Merge pull request #662 from chrocy/fix-export-excel-columns
feat: 新增 Excel 导出完整列开关
2026-04-07 19:33:14 +08:00
xuncha
01fc5cd1a0 导出在选择完整列的时候私聊不会有群昵称 2026-04-07 19:29:35 +08:00
xuncha
b12ffff310 Merge branch 'dev' into fix-export-excel-columns 2026-04-07 19:16:41 +08:00
xuncha
835359edf8 Merge branch 'main' into fix-export-excel-columns 2026-04-07 19:16:14 +08:00
xuncha
88817cf95e Merge pull request #664 from xunchahaha/dev
修复导出页意外的横向滑动条 朋友圈导出新增多选
2026-04-07 19:15:04 +08:00
xuncha
88d41f6857 修复导出页意外的横向滑动条 2026-04-07 19:09:16 +08:00
xuncha
ec9c1bbbba 朋友圈导出页新增多选功能 2026-04-07 19:09:01 +08:00
chrocy
f9313392f1 feat: 优化 Excel 导出设置,解决 #529,将「导出完整列」选项合并到「发送者名称显示」中 2026-04-07 16:44:59 +08:00
xuncha
2db8af3668 Merge pull request #650 from huanghe/fix/http-api-security
fix(security): harden HTTP API service against multiple vulnerabilities
2026-04-07 15:39:01 +08:00
xuncha
c56ba6e0a1 Merge branch 'dev' into fix/http-api-security 2026-04-07 15:35:46 +08:00
dependabot[bot]
f1dcc84991 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 22:30:51 +00:00
H3CoF6
e8aaae5616 Merge pull request #656 from H3CoF6/main
delete wayland notice
2026-04-07 03:44:19 +08:00
H3CoF6
45deb99e3d delete wayland notice 2026-04-07 03:37:11 +08:00
dependabot[bot]
28f6f966b9 chore(deps): bump sherpa-onnx-node from 1.12.34 to 1.12.35
Bumps [sherpa-onnx-node](https://github.com/csukuangfj/sherpa-onnx) from 1.12.34 to 1.12.35.
- [Release notes](https://github.com/csukuangfj/sherpa-onnx/releases)
- [Changelog](https://github.com/csukuangfj/sherpa-onnx/blob/dart-v1.12.35/CHANGELOG.md)
- [Commits](https://github.com/csukuangfj/sherpa-onnx/compare/dart-v1.12.34...dart-v1.12.35)

---
updated-dependencies:
- dependency-name: sherpa-onnx-node
  dependency-version: 1.12.35
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:13 +00:00
dependabot[bot]
7bd569feca chore(deps-dev): bump vite-plugin-electron from 0.28.8 to 0.29.1
Bumps [vite-plugin-electron](https://github.com/electron-vite/vite-plugin-electron) from 0.28.8 to 0.29.1.
- [Release notes](https://github.com/electron-vite/vite-plugin-electron/releases)
- [Changelog](https://github.com/electron-vite/vite-plugin-electron/blob/main/CHANGELOG.md)
- [Commits](https://github.com/electron-vite/vite-plugin-electron/compare/v0.28.8...v0.29.1)

---
updated-dependencies:
- dependency-name: vite-plugin-electron
  dependency-version: 0.29.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:10 +00:00
dependabot[bot]
056f2c1833 chore(deps-dev): bump sass from 1.98.0 to 1.99.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.98.0 to 1.99.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.98.0...1.99.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.99.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:10 +00:00
H3CoF6
b821d370f9 Merge pull request #655 from FATFATHAO/feat/linux-notification
[#654] fix: 更改linux中的消息通知走D-bus总线
2026-04-07 03:10:50 +08:00
fatfathao
60248b28f8 fix: 更改linux中的消息通知走D-bus总线 2026-04-07 01:30:26 +08:00
cc
d128bedffa 新增资源管理并修复了朋友圈的资源缓存路径 2026-04-06 23:32:59 +08:00
Jason
489b545965 Add files via upload 2026-04-06 21:01:24 +08:00
Jason
36533d07f8 Add files via upload 2026-04-06 21:01:00 +08:00
Jason
625e4f8e6a Merge pull request #13 from Jasonzhu1207/v0/jasonzhu081207-4751-f2dd3a17
Enable AI insights and Telegram push notifications
2026-04-06 20:39:32 +08:00
v0
c4774e1ce1 refactor: optimize insightService to skip getSessions() in whitelist mode
Eliminate unnecessary getSessions() calls and use lightweight queries for performance.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 12:33:11 +00:00
Jason
e1682f99d2 Merge pull request #12 from Jasonzhu1207/v0/jasonzhu081207-4751-9343a5f0
Enable AI insights and Telegram push notifications
2026-04-06 20:12:58 +08:00
v0
a23461bfce fix: optimize insightService for performance
Address DB connection issues, cache TTL, and timer handling to improve efficiency.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 12:06:02 +00:00
Jason
73fc36e63a Merge pull request #11 from Jasonzhu1207/v0/jasonzhu081207-4751-b8ccf9ee
Enable AI insights and Telegram push notifications
2026-04-06 19:31:12 +08:00
v0
4beddb7a62 fix: resolve main thread block and high CPU issues
Switch 'fs.appendFileSync' to 'fs.appendFile' and optimize 'getSessionsCached' to reduce DB access.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 11:28:33 +00:00
Jason
b130165831 Merge pull request #10 from Jasonzhu1207/v0/jasonzhu081207-4751-c8eef8af
Enable AI insights and Telegram push notifications
2026-04-06 18:59:09 +08:00
v0
9adffc3cd7 fix: resolve multiple issues and performance enhancement
Fix performance issue, Telegram prefix, and two encoding bugs; update custom prompt UI.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 10:55:30 +00:00
Jason
a52619c4d5 Merge pull request #9 from Jasonzhu1207/v0/jasonzhu081207-4751-0177d73e
Enable AI insights and Telegram push notifications
2026-04-06 18:13:03 +08:00
v0
cf40d3ad63 feat: optimize prompt caching and add Telegram push
Add system prompt caching, custom prompt, and Telegram push settings.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 09:56:11 +00:00
Ocean
f7f6252d0b Merge branch 'main' into fix/http-api-security 2026-04-06 14:11:17 +08:00
Jason
14a2475fb1 Add files via upload 2026-04-06 14:09:55 +08:00
Jason
76a55998c2 Add files via upload 2026-04-06 14:09:22 +08:00
Jason
1ec8d54e96 Merge branch 'hicccc77:main' into main 2026-04-06 14:07:31 +08:00
huanghe
62395b275d fix(security): harden HTTP API service against multiple vulnerabilities
1. Path traversal in /api/v1/media/ — use path.resolve() and verify
   resolved path stays within media base directory
2. DoS via unlimited POST body — add 10MB size limit to parseBody()
3. Default no-auth — reject all requests when httpApiToken is not
   configured instead of silently allowing everything
4. Overly permissive CORS — restrict Access-Control-Allow-Origin from
   wildcard (*) to localhost/127.0.0.1 only
5. Timing attack on token comparison — use crypto.timingSafeEqual()
   instead of === for token verification
6. Unsafe default bind address — revert httpApiHost default from
   0.0.0.0 back to 127.0.0.1 to prevent network exposure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:06:31 +08:00
cc
57fad47f27 Merge pull request #649 from hicccc77/dev
Dev
2026-04-06 13:45:04 +08:00
cc
20c5381211 更新 2026-04-06 13:23:16 +08:00
Jason
b8cd9a8c38 Merge branch 'hicccc77:main' into main 2026-04-06 13:13:15 +08:00
cc
4335abe31b 更新 2026-04-06 13:08:32 +08:00
cc
e5f7b54a7b Merge pull request #648 from hicccc77/main
Merge pull request #647 from hicccc77/dev
2026-04-06 13:06:33 +08:00
cc
ea1ef03b98 Merge pull request #647 from hicccc77/dev
Dev
2026-04-06 13:06:10 +08:00
cc
8d374d4f49 Merge branch 'main' into dev 2026-04-06 13:06:02 +08:00
cc
f910e17e53 Merge pull request #644 from fortii2/fix/export-worker-config
#580 修复与部分引用功能相关联的无法读取解密配置的问题
2026-04-06 13:04:13 +08:00
cc
35a76aa04f Merge pull request #643 from fortii2/issue-580-partial-quote
#580 引用消息支持部分引用显示和导出
2026-04-06 12:58:57 +08:00
cc
5fce21d799 Merge pull request #641 from FATFATHAO/fix-package
fix: node25使用pnpm拉取文件时,ajv导致拉取失败的问题
2026-04-06 12:52:38 +08:00
cc
a32696ee13 Merge branch 'dev' into fix-package 2026-04-06 12:52:18 +08:00
cc
b573baec80 Merge pull request #646 from hicccc77/dev
Dev
2026-04-06 12:49:47 +08:00
cc
0d4feceffc Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-06 12:48:59 +08:00
cc
92abe73f0a 更新 2026-04-06 12:48:53 +08:00
Jason
7fa26b0716 Merge pull request #8 from Jasonzhu1207/v0/jasonzhu081207-4751-1e322b3f
Enable AI insights and system-native notifications
2026-04-06 12:43:38 +08:00
Jason
dc49bf3877 Update package.json 2026-04-06 12:29:51 +08:00
v0
d825dada59 fix: correct electron-builder upload for prerelease tags
Remove 'releaseType: "release"' to allow automatic handling of prerelease tags.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 04:28:32 +00:00
cc
74a08732fe Merge pull request #645 from hicccc77/dev
修复了一些问题
2026-04-06 12:16:38 +08:00
cc
7033a77d71 Merge branch 'main' into dev 2026-04-06 12:16:28 +08:00
cc
3b26e0c014 修复了一些问题 2026-04-06 12:15:50 +08:00
Jason
81ec51be33 Update release.yml 2026-04-06 12:09:14 +08:00
Jason
fbecda9f1e Update release.yml 2026-04-06 11:59:57 +08:00
v0
b6950d4027 fix: correct GitHub Actions release download failure
Add '|| true' to suppress exit code from failed downloads

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 03:58:10 +00:00
Jason
f31327b528 Merge pull request #7 from Jasonzhu1207/v0/jasonzhu081207-4751-e705ab05
Enable AI insights and system-native notifications
2026-04-06 11:39:56 +08:00
v0
c4c7df2608 fix: resolve insight tab loading and performance issues
Fix chat session loading logic and optimize session retrieval performance.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 03:35:39 +00:00
ethan
b8bf29277a 修复与部分引用功能相关联的无法读取解密配置的问题 2026-04-05 17:48:12 -04:00
ethan
867f85e8f2 实现 #580 引用消息支持部分引用显示 2026-04-05 17:39:22 -04:00
Jason
7fb98d764a Merge pull request #6 from Jasonzhu1207/v0/jasonzhu081207-4751-03d90813
Enable AI insights and system-native notifications
2026-04-06 01:49:04 +08:00
v0
792621d982 feat: use Electron's native Notification API for reliable alerts
Replace custom 'showNotification' with Electron's 'Notification' for system-level alerts.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:47:14 +00:00
fatfathao
337fe21d18 fix: node25使用pnpm拉取文件时,ajv导致拉取失败的问题 2026-04-06 01:40:06 +08:00
Jason
c92b50b6ec Merge pull request #5 from Jasonzhu1207/v0/jasonzhu081207-4751-8b63b98d
Enable AI insights and whitelist management in settings
2026-04-06 01:35:19 +08:00
v0
f83117df20 feat: update prompt to force insights output
Modify prompt to encourage model to output insights, disallow SKIP in test mode.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:33:09 +00:00
Jason
b7b7260838 Merge pull request #4 from Jasonzhu1207/v0/jasonzhu081207-4751-507441fc
Enable AI insights and whitelist management in settings
2026-04-06 01:22:46 +08:00
v0
dd960d30ff fix: remove leftover old catch block
Clean up mismatched catch block from previous edit.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:21:24 +00:00
v0
89f3ec57f5 feat: add configurable AI insight settings and desktop logging
Introduce new configurable fields and log insights to desktop.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:20:23 +00:00
v0
95f1e73a39 fix: resolve core bugs and enhance logging for AI insights
Fix aggressive activity analysis and loop bug, add detailed logs, and introduce test trigger button.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:11:05 +00:00
Jason
aa029fe113 Merge pull request #3 from Jasonzhu1207/v0/jasonzhu081207-4751-c1e23024
Enable AI insights and whitelist management in settings
2026-04-06 00:45:11 +08:00
v0
5971757a28 feat: add aiInsightWhitelist to settings page
Implement aiInsightWhitelist feature with UI and filtering logic.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 16:42:43 +00:00
Jason
1e16ea887b Merge pull request #2 from Jasonzhu1207/v0/jasonzhu081207-4751-3942175b
Add AI insights service and settings tab
2026-04-06 00:12:13 +08:00
v0
837f15c5e8 fix: update repository owner and URL in electron-builder config
Correct hardcoded owner and repository URL in package.json for proper release publishing.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 16:10:37 +00:00
Jason
f71ff7392c Update package.json 2026-04-05 23:59:09 +08:00
Jason
97ba95e2be Update repository URL in package.json 2026-04-05 23:58:17 +08:00
v0
6aae23180f fix: resolve TypeScript errors in GitHub Actions build
Fix type issues and update import syntax for better compatibility.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:51:40 +00:00
v0
49e82e43e4 fix: resolve TypeScript type issues in CI builds
Fix multiple type errors and improve type checks in build scripts.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:50:00 +00:00
Jason
301c490893 Merge pull request #1 from Jasonzhu1207/ai
Add AI insights service and settings tab
2026-04-05 23:33:04 +08:00
v0
93a9df48f4 feat: implement AI insights service and settings tab
Add core insight service and IPC handlers; update config and settings page.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:32:22 +00:00
cc
209b91bfef Merge pull request #638 from hicccc77/dev
Dev
2026-04-05 19:21:28 +08:00
cc
1049f55118 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-05 14:53:14 +08:00
cc
ba7785a359 修复发布日期问题 2026-04-05 14:53:11 +08:00
cc
e6c821d3ee Merge pull request #637 from hicccc77/dev
交互细节修复与代码修复
2026-04-05 11:24:35 +08:00
cc
17a7741697 Merge branch 'main' into dev 2026-04-05 11:24:26 +08:00
cc
f00525d21a 交互细节修复与代码修复 2026-04-05 10:57:49 +08:00
cc
f5c79c1fab Merge pull request #636 from hicccc77/dev
Dev
2026-04-04 23:27:27 +08:00
cc
4fc0a92651 更新资源文件 2026-04-04 23:25:21 +08:00
cc
585ec39f8e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-04 23:14:57 +08:00
cc
a0189fdd0a 修复 #597;实现 #556;修复 #623与 #543;修复卡片图片问题 2026-04-04 23:14:54 +08:00
cc
ede31732b3 Merge pull request #634 from BeiChen-CN/main
feat:支持导出聊天记录中的文件
2026-04-04 20:16:05 +08:00
姜北尘
a60381522d fix 2026-04-04 20:04:01 +08:00
姜北尘
64010ad86b feat:添加导出文件 2026-04-04 19:45:05 +08:00
cc
e628154b78 Merge pull request #632 from hicccc77/dev
Dev
2026-04-04 14:04:47 +08:00
cc
e5baf5e994 Merge branch 'main' into dev 2026-04-04 14:04:35 +08:00
cc
05fdbab496 更新信息 2026-04-04 13:26:06 +08:00
cc
512b1f6455 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-04 10:57:46 +08:00
cc
5615d83f04 修复更新渠道问题 2026-04-04 10:57:43 +08:00
cc
ee38918516 Merge pull request #630 from hicccc77/dev
Dev
2026-04-04 09:54:46 +08:00
H3CoF6
d1b8d86a20 Merge pull request #625 from H3CoF6/dev
修复biz的一些问题
2026-04-04 02:58:54 +08:00
H3CoF6
25ef7c5d8a 更快的排序 2026-04-04 02:52:12 +08:00
H3CoF6
db429abf5b 时间排序 2026-04-04 02:34:57 +08:00
H3CoF6
19d5ae7e15 fix: 修复账号类型,删除广告账号 2026-04-04 01:53:03 +08:00
cc
fcbd613f4a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 23:23:47 +08:00
cc
5fae370c55 更新打包 2026-04-03 23:23:42 +08:00
xuncha
f2dbe6ee8f Merge pull request #622 from xunchahaha/dev
Dev
2026-04-03 21:11:08 +08:00
xuncha
0175a6998b Merge branch 'dev' into dev 2026-04-03 21:08:36 +08:00
xuncha
758de9949b 新增开机自启动 [Enhancement]: 希望能够支持静默启动和开机自启动
Fixes #516
2026-04-03 21:08:05 +08:00
xuncha
81b8960d41 双人年度报告支持导出 [Enhancement]: 双人年度报告不支持导出 但总年度报告支持
Fixes #531
2026-04-03 21:07:44 +08:00
xuncha
5b25619b24 Merge pull request #620 from xunchahaha/dev
卡片链接新增解析
2026-04-03 20:50:44 +08:00
xuncha
62e23aaf23 卡片链接新增解析 2026-04-03 20:47:15 +08:00
cc
aac8eed898 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 20:35:10 +08:00
cc
108980befb 修复了一些问题 2026-04-03 20:34:57 +08:00
chrocy
6a4cd00d51 fix(export): 补全导出弹窗中缺失的 Excel 完整列切换开关 (fix #529) 2026-04-03 20:24:59 +08:00
xuncha
a6c899c098 Merge pull request #557 from jinkangHe/dev
feat(sns):增加朋友圈相关api
2026-04-03 20:14:57 +08:00
xuncha
28170d31df Merge branch 'dev' into dev 2026-04-03 20:11:25 +08:00
cc
ce8d272d6e Merge pull request #619 from hicccc77/dev
Dev
2026-04-03 20:10:37 +08:00
cc
0047685f54 修复了一些问题 2026-04-03 20:09:37 +08:00
xuncha
2cc0fc64a4 Merge branch 'dev' into dev 2026-04-03 20:08:03 +08:00
xuncha
67642cebfd fix(http): stream live sns media and clarify docs 2026-04-03 20:07:11 +08:00
cc
327dc85d14 优化通道结构 2026-04-03 20:05:23 +08:00
cc
8c4f42bab1 Merge branch 'dev' into dev 2026-04-03 19:52:35 +08:00
cc
40c29e494c 更新配置文件 2026-04-03 19:49:43 +08:00
xuncha
0235ec7edc Merge branch 'dev' into dev 2026-04-03 19:49:29 +08:00
cc
fa2a000624 Merge pull request #617 from hicccc77/dependabot/npm_and_yarn/dev/electron-store-11.0.2
chore(deps): bump electron-store from 10.1.0 to 11.0.2
2026-04-03 19:43:41 +08:00
dependabot[bot]
861b24cef1 chore(deps): bump electron-store from 10.1.0 to 11.0.2
Bumps [electron-store](https://github.com/sindresorhus/electron-store) from 10.1.0 to 11.0.2.
- [Release notes](https://github.com/sindresorhus/electron-store/releases)
- [Commits](https://github.com/sindresorhus/electron-store/compare/v10.1.0...v11.0.2)

---
updated-dependencies:
- dependency-name: electron-store
  dependency-version: 11.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 11:42:44 +00:00
cc
ee1977384e Merge pull request #616 from hicccc77/dependabot/npm_and_yarn/dev/react-router-dom-7.14.0
chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
2026-04-03 19:41:56 +08:00
cc
5d08505f62 Merge pull request #614 from hicccc77/dependabot/npm_and_yarn/dev/electron-41.1.1
chore(deps-dev): bump electron from 39.8.6 to 41.1.1
2026-04-03 19:41:32 +08:00
cc
ab21124327 Merge branch 'dev' into dependabot/npm_and_yarn/dev/electron-41.1.1 2026-04-03 19:41:16 +08:00
cc
1df792ec9c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 19:35:11 +08:00
cc
a8fa6e5987 修复了一些打包问题 2026-04-03 19:34:32 +08:00
dependabot[bot]
1d69c5a78d chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.13.2 to 7.14.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:50 +00:00
dependabot[bot]
0ae7ba3e11 chore(deps-dev): bump electron from 39.8.6 to 41.1.1
Bumps [electron](https://github.com/electron/electron) from 39.8.6 to 41.1.1.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v39.8.6...v41.1.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:24 +00:00
H3CoF6
c421ca7f2f Merge pull request #613 from H3CoF6/dev
feat: 公众号服务号内容单独解析
2026-04-03 06:00:43 +08:00
H3CoF6
ea4fff5b10 Merge remote-tracking branch 'upstream/main' into dev 2026-04-03 05:45:10 +08:00
H3CoF6
e0b0e38271 fix: 服务号类型说明 2026-04-03 05:44:19 +08:00
H3CoF6
510b956649 refactor: 样式对齐 2026-04-03 05:20:58 +08:00
H3CoF6
17b8af4bc4 fix: 删除广告,增添无记录显示 2026-04-03 04:48:39 +08:00
H3CoF6
617b400884 feat: 以chat的方式实现biz的解析 2026-04-03 04:40:34 +08:00
cc
a58518ccb5 Merge pull request #611 from hicccc77/dev
Dev
2026-04-03 00:01:58 +08:00
cc
cdd17d919e Merge branch 'main' into dev 2026-04-03 00:01:49 +08:00
cc
4580cef7f2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 00:01:18 +08:00
cc
6f9c765bab 更新依赖 2026-04-03 00:01:05 +08:00
H3CoF6
5b56b2e0be Merge remote-tracking branch 'upstream/dev' into dev 2026-04-02 23:50:35 +08:00
cc
b0cc811807 Merge pull request #610 from hicccc77/dev
Dev
2026-04-02 23:30:36 +08:00
cc
eb540d5c13 Merge branch 'main' into dev 2026-04-02 23:30:29 +08:00
cc
e308293cf6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:29:50 +08:00
cc
9ed4659c5c 修复依赖问题 2026-04-02 23:28:55 +08:00
cc
f5f2b76914 Merge pull request #609 from hicccc77/dev
Dev
2026-04-02 23:23:07 +08:00
cc
551a065497 Merge branch 'main' into dev 2026-04-02 23:22:59 +08:00
cc
88d7e38d82 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:22:16 +08:00
cc
65e6cb22dd 修复下载源问题 2026-04-02 23:22:12 +08:00
cc
689a396f6e Merge pull request #608 from hicccc77/dev
Dev
2026-04-02 23:16:30 +08:00
cc
512ea84850 Merge branch 'main' into dev 2026-04-02 23:16:19 +08:00
cc
1542e583f7 修复了一些问题 2026-04-02 23:15:39 +08:00
cc
c488dcc3c6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:15:20 +08:00
cc
1594e0e24b 修复安装问题 2026-04-02 23:15:17 +08:00
cc
1e1fa77621 Merge pull request #607 from hicccc77/dev
Dev
2026-04-02 23:04:50 +08:00
cc
3270c17514 Merge branch 'main' into dev 2026-04-02 23:04:28 +08:00
cc
e635942e3d 补充更新 2026-04-02 23:03:50 +08:00
cc
64dc2858a7 补充更新 2026-04-02 23:02:36 +08:00
cc
d05496bb3d 补充更新 2026-04-02 23:02:24 +08:00
cc
bb94553fff 补充更新 2026-04-02 23:02:15 +08:00
cc
113216b7ba 修复打包问题 2026-04-02 23:01:54 +08:00
cc
55181edaa8 Merge pull request #599 from hicccc77/dependabot/npm_and_yarn/dev/lucide-react-1.7.0
chore(deps): bump lucide-react from 0.562.0 to 1.7.0
2026-04-02 23:01:20 +08:00
cc
4f01a7b577 Merge branch 'dev' into dependabot/npm_and_yarn/dev/lucide-react-1.7.0 2026-04-02 23:01:08 +08:00
cc
ea21111037 Merge pull request #600 from hicccc77/dependabot/npm_and_yarn/dev/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
2026-04-02 23:00:57 +08:00
dependabot[bot]
fba9f1de42 chore(deps): bump lucide-react from 0.562.0 to 1.7.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.562.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:21 +00:00
dependabot[bot]
7d0b8db7a6 chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:14 +00:00
cc
12faa31e34 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:51:48 +08:00
cc
74945b1752 修复了一些打包问题 2026-04-02 22:51:44 +08:00
cc
a7c66517d2 Merge pull request #606 from hicccc77/dev
Dev
2026-04-02 22:42:32 +08:00
cc
adf187ddf5 Merge branch 'main' into dev 2026-04-02 22:42:13 +08:00
cc
614d897dd2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:41:42 +08:00
cc
ec9a7d68e6 修复了一些问题 2026-04-02 22:41:36 +08:00
cc
79dd91b270 Merge pull request #605 from hicccc77/dev
Dev
2026-04-02 22:31:26 +08:00
cc
b26bcd7603 Merge branch 'main' into dev 2026-04-02 22:31:25 +08:00
cc
a65468191b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:30:53 +08:00
cc
4ed5271703 修复管理器问题 2026-04-02 22:30:49 +08:00
cc
f2afe2a977 Merge pull request #604 from hicccc77/dev
Dev
2026-04-02 22:28:35 +08:00
cc
430b0f30c9 Merge branch 'main' into dev 2026-04-02 22:28:24 +08:00
cc
8aac9a795e 修复检测问题与依赖问题 2026-04-02 22:27:45 +08:00
cc
b9405765f9 Merge pull request #601 from hicccc77/dependabot/npm_and_yarn/dev/echarts-6.0.0
chore(deps): bump echarts from 5.6.0 to 6.0.0
2026-04-02 22:25:00 +08:00
cc
90856b3812 Merge pull request #602 from hicccc77/dependabot/npm_and_yarn/dev/sass-1.98.0
chore(deps-dev): bump sass from 1.97.2 to 1.98.0
2026-04-02 22:24:49 +08:00
cc
1e78af3c25 Merge pull request #603 from hicccc77/dependabot/npm_and_yarn/dev/vite-8.0.3
chore(deps-dev): bump vite from 6.4.1 to 8.0.3
2026-04-02 22:24:38 +08:00
dependabot[bot]
4be232b951 chore(deps-dev): bump vite from 6.4.1 to 8.0.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 8.0.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:22:00 +00:00
dependabot[bot]
59d5c2762d chore(deps-dev): bump sass from 1.97.2 to 1.98.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.97.2 to 1.98.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.97.2...1.98.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.98.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:28 +00:00
dependabot[bot]
21a55439ec chore(deps): bump echarts from 5.6.0 to 6.0.0
Bumps [echarts](https://github.com/apache/echarts) from 5.6.0 to 6.0.0.
- [Release notes](https://github.com/apache/echarts/releases)
- [Commits](https://github.com/apache/echarts/compare/5.6.0...6.0.0)

---
updated-dependencies:
- dependency-name: echarts
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:19 +00:00
cc
c4a35f5c15 Merge pull request #598 from hicccc77/dev
Dev
2026-04-02 22:20:04 +08:00
cc
b42f761011 配置更新 2026-04-02 22:18:50 +08:00
cc
46e2e64e65 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:14:52 +08:00
cc
54ed35ffb3 新增渠道更新 2026-04-02 22:14:46 +08:00
cc
7b8bd747ad Merge branch 'dev' into dev 2026-04-02 21:39:55 +08:00
cc
3e379957e1 Merge pull request #596 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-282a1442c2
chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in the npm_and_yarn group across 1 directory
2026-04-02 20:53:57 +08:00
dependabot[bot]
b64525487e chore(deps-dev): bump @xmldom/xmldom
Bumps the npm_and_yarn group with 1 update in the / directory: [@xmldom/xmldom](https://github.com/xmldom/xmldom).


Updates `@xmldom/xmldom` from 0.8.11 to 0.8.12
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 05:11:30 +00:00
hicccc77
e544f5c862 fix: add forceReopen retry logic for openMessageCursorLite 2026-04-01 04:04:53 +08:00
hicccc77
669759c52e chore: 更新资源文件 2026-03-31 21:36:53 +08:00
cc
4a13d3209b 更新 2026-03-31 21:24:45 +08:00
cc
be069e9aed 实现 #584 2026-03-31 21:24:31 +08:00
hicccc77
0b20ee1aa2 chore: 更新资源文件 2026-03-31 20:02:07 +08:00
hejk
e4872a78f5 feat(http): change default API host to 0.0.0.0 for external access
- 修改 httpApiHost 默认值从 127.0.0.1 到 0.0.0.0
- 允许 HTTP API 服务接受外网访问
- 用户需要配置 httpApiToken 以确保安全

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:32:46 +08:00
H3CoF6
4692216325 Merge remote-tracking branch 'upstream/dev' into dev 2026-03-31 00:18:34 +08:00
hicccc77
69128062fe chore: 更新资源文件 2026-03-30 23:56:34 +08:00
H3CoF6
f81ba3028d Merge remote-tracking branch 'upstream/dev' into dev 2026-03-30 20:36:36 +08:00
H3CoF6
73a948c528 feat: 初步实现服务号/公众号解析 2026-03-30 20:36:20 +08:00
hicccc77
6d652130e6 chore: 更新资源文件 2026-03-30 20:10:35 +08:00
hicccc77
9e6f8077f7 fix: 数据匿名收集受 analyticsConsent 开关控制 (#589)
- 新增 analyticsConsent state 追踪用户同意状态
- initCloudControl() 仅在用户明确同意后执行
- recordPage() 同样受 analyticsConsent 守卫
- handleAnalyticsAllow 同步更新 state,用户同意后立即生效

Fixes #589
2026-03-30 12:05:41 +08:00
hicccc77
40342ca824 fix(deps): 修复 npm install postinstall 阶段 ajv-keywords 兼容性错误
将 npm overrides 中的 ajv 版本范围从 >=6.14.0 改为 ^6.14.0,
确保 electron-builder 依赖链使用 ajv v6,避免在 Node.js v22 上
@develar/schema-utils 加载 ajv-keywords 时访问 formats 返回 undefined 的问题。

Fixes #588
2026-03-30 11:04:44 +08:00
hejinkang
71238d4a01 Merge branch 'dev' into dev 2026-03-30 10:04:56 +08:00
hicccc77
4da9f1e6cf feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:19:55 +08:00
hicccc77
93b55fe370 feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:18:24 +08:00
hicccc77
ee5e7d2586 fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 19:18:20 +08:00
hicccc77
4f4e09c3de fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 16:07:09 +08:00
hicccc77
d537d81f1c fix(deps): 修复安全漏洞 2026-03-28 21:15:14 +08:00
hicccc77
26c6700152 fix: 修复 CodeQL code scanning warning 问题 2026-03-28 21:12:29 +08:00
hicccc77
49fb96d7a3 Revert "Revert "fix(deps): 修复安全漏洞""
This reverts commit d256ee5696.
2026-03-28 19:29:17 +08:00
hicccc77
d256ee5696 Revert "fix(deps): 修复安全漏洞"
This reverts commit 06079659af.
2026-03-28 19:28:45 +08:00
hicccc77
bd70a7bfa8 Revert "fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575"
This reverts commit 3fb09bad0d.
2026-03-28 19:28:45 +08:00
hicccc77
3fb09bad0d fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575 2026-03-28 19:05:21 +08:00
hicccc77
06079659af fix(deps): 修复安全漏洞 2026-03-28 17:36:28 +08:00
hicccc77
22d8049c2c Revert "fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题"
This reverts commit 5f6b0e8960.
2026-03-28 17:30:56 +08:00
hicccc77
5f6b0e8960 fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题
修复 scanWxids 和 scanWxidCandidates 在 2.0b4.0.9/xwechat_files/wxid_xxx
结构下扫描不到账号目录的问题,增加往下多扫一层的兜底逻辑

Fixes #541
2026-03-28 17:28:52 +08:00
hicccc77
9b8da7774d fix: 替换失效的 downloads badge 为 shields.io 2026-03-28 17:05:33 +08:00
hicccc77
eabed55a7a fix: 修复 README Downloads badge 嵌套在 Issues 链接内的问题 2026-03-28 17:03:28 +08:00
hicccc77
32cc74f99c merge: 同步 main 最新代码到 dev(依赖更新、版本 4.3.0、资源文件) 2026-03-28 16:54:47 +08:00
cc
ffc4cc3d96 Merge pull request #574 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-8abc9b7730
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
2026-03-28 16:50:28 +08:00
dependabot[bot]
007cf57efd chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the / directory: [minimatch](https://github.com/isaacs/minimatch), [brace-expansion](https://github.com/juliangruber/brace-expansion) and [rollup](https://github.com/rollup/rollup).


Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

Updates `rollup` from 4.55.1 to 4.60.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.55.1...v4.60.0)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.60.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 07:56:41 +00:00
hicccc77
c6dba71197 fix: 修复 macOS release notes 中 xattr 命令被 bash 吞掉的问题 2026-03-28 15:55:11 +08:00
cc
8aa162e294 Merge pull request #568 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-1ca40131d0
chore(deps): bump @tootallnate/once from 2.0.0 to removed in the npm_and_yarn group across 1 directory
2026-03-28 14:51:11 +08:00
dependabot[bot]
51d6dec7ff chore(deps): bump @tootallnate/once
Bumps the npm_and_yarn group with 1 update in the / directory: [@tootallnate/once](https://github.com/TooTallNate/once).


Removes `@tootallnate/once`

---
updated-dependencies:
- dependency-name: "@tootallnate/once"
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 22:39:12 +00:00
hicccc77
f1b2762769 fix(deps): 修复安全漏洞 2026-03-28 06:37:44 +08:00
hicccc77
d126be2aa5 chore: 更新资源文件 2026-03-27 22:03:27 +08:00
hicccc77
ea034ee76a ci: fix pnpm audit exit code causing job failure 2026-03-27 21:43:16 +08:00
hicccc77
39634a690c fix(deps): remove ajv override to fix electron-builder compatibility 2026-03-27 21:09:28 +08:00
hicccc77
a7001eb6da fix(deps): upgrade react-router-dom to 7.13.2 and add pnpm overrides for security vulnerabilities
- Upgrade react-router-dom ^7.1.1 -> ^7.13.2
- Add pnpm.overrides to force safe versions of: tar, minimatch, rollup,
  immutable, lodash, ajv, brace-expansion, picomatch
2026-03-27 21:04:44 +08:00
hicccc77
71e3540f18 ci: add gitleaks config to suppress false positives 2026-03-27 20:58:14 +08:00
cc
4ea4020faa Merge branch 'dev' into dev 2026-03-27 20:53:56 +08:00
hicccc77
78cadfd352 ci: add security-events write permission for CodeQL 2026-03-27 19:12:31 +08:00
hicccc77
da15f829d3 ci: add security-events write permission for CodeQL 2026-03-27 19:12:20 +08:00
hicccc77
bb60694013 ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:26 +08:00
hicccc77
b3758d2baf ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:14 +08:00
hicccc77
bc794e9a44 ci: add daily security scan workflow for all branches 2026-03-27 17:59:09 +08:00
hicccc77
c80115d0f7 ci: add daily security scan workflow for all branches 2026-03-27 17:56:35 +08:00
xuncha
6277576249 Merge pull request #560 from JiQingzhe2004/main
feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用
2026-03-27 15:23:02 +08:00
JiQingzhe2004
2201d369fa chore: bump version to 4.3.0 2026-03-27 14:43:33 +08:00
JiQingzhe2004
9f4e4790f5 feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用 2026-03-27 14:43:08 +08:00
xuncha
501e373e38 Merge pull request #559 from xunchahaha/main
更新打包
2026-03-27 13:10:50 +08:00
xuncha
b2cf7c92d5 更新打包 2026-03-27 13:10:27 +08:00
xuncha
e92e13c045 Merge pull request #558 from hicccc77/xunchahaha-patch-1
Delete preinstall.js
2026-03-27 12:59:45 +08:00
xuncha
f3dec958b0 Delete preinstall.js 2026-03-27 12:48:42 +08:00
hejinkang
c88a1c5848 Merge branch 'hicccc77:dev' into dev 2026-03-27 10:08:08 +08:00
hicccc77
0cf8ea8166 chore: bump version to 4.2.0 2026-03-26 23:20:42 +08:00
hicccc77
74b830dd79 chore: update service files and xkey_helper
- xkey_helper: use mach exception port to intercept EXC_BREAKPOINT,
  fixes key capture failure on macOS 26.2
2026-03-26 23:19:08 +08:00
cc
8668c168a7 333 2026-03-26 22:43:59 +08:00
cc
8b8c5f33ce 333 2026-03-26 22:34:50 +08:00
cc
2fcbb026df 222 2026-03-26 22:32:33 +08:00
cc
66ee72380d 222 2026-03-26 22:30:21 +08:00
cc
4f16345351 111 2026-03-26 22:26:33 +08:00
cc
5110618996 再次修复 2026-03-26 22:19:30 +08:00
cc
bf51368cf4 修复密钥问题 2026-03-26 22:16:30 +08:00
cc
d6054745d6 修复macos打包错误 2026-03-26 22:00:42 +08:00
hicccc77
a4731f25f8 chore: update xkey_helper (macOS) with pure semantic scan mode
Always use pure semantic scan mode (KNOWN_RVA=0) regardless of
WeChat version, improving compatibility for versions < 4.1.8.
2026-03-26 21:16:14 +08:00
hicccc77
6c4507e495 fix(ci): remove invalid --no-fail-on-no-release flag from gh release edit 2026-03-26 20:33:18 +08:00
hicccc77
c8e0160d5c fix(ci): use bash shell for Windows packaging steps to avoid PowerShell variable expansion 2026-03-26 20:21:13 +08:00
hicccc77
ac40a81901 fix(ci): pre-release placeholder + fix latest.yml arm64 overwrite
- Add prepare-release job: immediately marks release as pre-release
  with "正在构建中,请勿下载" notice; all build jobs depend on it
- Fix arm64 job channel override: use -c.publish.channel=latest-arm64
  (correct syntax) instead of broken single-quoted CLI arg
- Fix artifactName quoting for both win x64 and arm64 jobs
- Add "Fix latest.yml" step in update-release-notes: downloads x64 exe,
  computes correct sha512/size, uploads latest.yml with --clobber
- Final step in update-release-notes: remove prerelease flag, mark as
  latest official release

Fixes #553
2026-03-26 19:57:45 +08:00
hejk
0162769d22 fix(sns): fallback usernames from timeline when SQL result is empty 2026-03-26 19:38:06 +08:00
hejk
fa55755921 feat(http): add sns HTTP API endpoints 2026-03-26 19:36:19 +08:00
hicccc77
ca38a68a75 fix: 改善错误码 -3001 提示信息并增强 db_storage 路径解析兼容性\n\n- formatInitProtectionError 返回可读的中文错误说明,替代裸错误码\n- resolveDbStoragePath 新增向上查找(最多2级)兜底逻辑\n- 新增 findDbStorageRecursive 递归搜索(最多3层)兜底\n- 解决使用 wx_key 获取密钥后因路径层级不同导致 -3001 报错的问题\n\nFixes #552 2026-03-26 15:08:08 +08:00
hicccc77
64be2dd562 fix: 支持微信 4.0.5+ 新数据目录结构 (Application Support/com.tencent.xinWeChat/2.0b4.0.x)
- dbPathService.autoDetect: 自动枚举版本目录(如 2.0b4.0.9),优先检测新路径
- dbPathService.getDefaultPath: 同步返回新版本路径
- keyServiceMac.resolveXwechatRootFromPath: 兼容新路径标记
- keyServiceMac.getKvcommCandidates: 补充新路径下的 kvcomm 推导

Fixes #551
2026-03-26 12:08:47 +08:00
cc
ea2abb6c72 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-25 20:09:53 +08:00
cc
011e2ff37a 修复Action打包问题与渲染层过滤问题 2026-03-25 20:09:48 +08:00
cc
cfa335564a Merge pull request #549 from hicccc77/dev
Dev
2026-03-25 20:02:58 +08:00
cc
3d1493b0a6 Merge pull request #548 from Leoluis0705/perf/optimize-group-analytics
Perf/optimize group analytics
2026-03-25 19:51:08 +08:00
Leoluis0705
a46b52e603 fix(analytics): 完善群成员分析失败时的错误边界处理与UI展示 2026-03-25 19:15:12 +08:00
Leoluis0705
3c0683b9f8 perf(core): 为底层提取器引入 isSend 标识智能判断,解决大量本地消息及富文本消息引发的性能退化问题 2026-03-25 18:30:24 +08:00
Leoluis0705
3214c2804e feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能 2026-03-25 18:24:05 +08:00
hicccc77
83f50cbaee fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH
- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
2026-03-25 15:10:16 +08:00
Forrest
61ef10de9b Merge pull request #545 from JiQingzhe2004/main
更新图标
2026-03-25 02:09:50 +08:00
Forrest
73f36d6b29 更新图标 2026-03-25 01:36:04 +08:00
Forrest
666a1a3296 Merge branch 'hicccc77:main' into main 2026-03-25 00:18:12 +08:00
H3CoF6
acec2e95a2 Merge pull request #540 from H3CoF6/main
Dev
2026-03-24 04:45:13 +08:00
H3CoF6
d26e7e78a1 支持appimage,添加安装脚本,更新文档 2026-03-24 04:33:17 +08:00
H3CoF6
77e5c44673 feat: 保存api服务的配置,实现随weflow静默启动 2026-03-24 04:11:34 +08:00
H3CoF6
619cc84d15 feat: api接口新增access_token校验 2026-03-24 03:55:37 +08:00
H3CoF6
22b85439d3 chore: 向下兼容低版本linux 2026-03-24 03:14:44 +08:00
cc
64995c25a8 支持联系人签名、标签分组、地区获取;优化导出效果 2026-03-23 21:46:15 +08:00
cc
1655b5ae78 Merge pull request #528 from BeiChen-CN/main
feat: 导出联系人标签和详细描述
2026-03-23 19:34:57 +08:00
cc
3d3f6d058e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 23:34:57 +08:00
cc
104a04c5de 修复Windows hello部分情况下设置失败的问题 2026-03-22 23:34:48 +08:00
姜北尘
e12193aa40 feat: 导出联系人标签和详细描述
扩展联系人读取与导出链路,新增 labels 和 detailDescription 字段的兼容提取,并同步更新通讯录缓存、详情展示与
  JSON/CSV/VCF 导出。
  Close #402
2026-03-22 14:17:19 +08:00
hicccc77
51101387f7 资源文件同步 2026-03-22 11:31:27 +08:00
cc
641a3bf2ab 修复群导出时的错误昵称判定;修复引用样式的一些错误;修复打包问题 2026-03-22 11:25:59 +08:00
cc
58f22f4bb2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-22 10:32:47 +08:00
cc
562eac4249 修复release问题 2026-03-22 10:32:42 +08:00
xuncha
2e1c0e6c54 Merge pull request #524 from hicccc77/dev
Dev
2026-03-22 09:38:40 +08:00
xuncha
7759868664 Merge pull request #523 from xunchahaha:dev
Dev
2026-03-22 09:37:56 +08:00
xuncha
e92df66bef 修复导出页头像缺失 2026-03-22 09:37:19 +08:00
xuncha
354f3fd8e2 修复图片解密失败 2026-03-22 09:18:57 +08:00
xuncha
1201ea33db Merge pull request #521 from BeiChen-CN/main
feat: 支持自定义引用消息样式
2026-03-21 23:00:23 +08:00
姜北尘
f8e99a34c7 feat: 支持自定义引用消息样式
允许用户在设置中切换引用消息与正文的上下顺序,并使聊天页中的引用回复即时按所选样式展示。
  Close#510
2026-03-21 22:26:09 +08:00
hicccc77
1cef17174b chore: 更新资源文件 2026-03-21 21:45:53 +08:00
cc
73cabf2acd 修复闪退问题 2026-03-21 21:41:32 +08:00
cc
d16423818d Merge pull request #518 from hicccc77/dev
Dev
2026-03-21 15:53:54 +08:00
xuncha
b5a371da87 Merge pull request #349 from hicccc77/dev
Dev
2026-03-13 08:55:32 +03:00
140 changed files with 41919 additions and 4714 deletions

7
.github/dependabot.yml vendored Normal file
View 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
View 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
View 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})
- macOSApple 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}'

View 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})
- macOSApple 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}'

View File

@@ -10,6 +10,7 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
release-mac-arm64:
@@ -30,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: |
@@ -38,6 +55,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
@@ -45,12 +63,29 @@ jobs:
- name: Package and Publish macOS arm64 (unsigned DMG)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
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 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
@@ -77,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
@@ -84,11 +120,23 @@ jobs:
- name: Package and Publish Linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
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
@@ -115,6 +163,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
@@ -122,10 +171,22 @@ jobs:
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
run: |
npx electron-builder --publish always
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: |
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
@@ -153,6 +214,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
@@ -160,10 +222,22 @@ jobs:
- name: Package and Publish Windows arm64
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
WF_SIGNING_REQUIRED: "1"
run: |
npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
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: |
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
@@ -192,10 +266,12 @@ jobs:
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
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="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
@@ -209,7 +285,6 @@ jobs:
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
@@ -221,14 +296,18 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
- Windows x64Win10+: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSM系列芯片: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md

96
.github/workflows/security-scan.yml vendored Normal file
View 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

6
.gitignore vendored
View File

@@ -56,6 +56,8 @@ Thumbs.db
*.aps
wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/
server/
*info
@@ -70,3 +72,7 @@ resources/wx_send
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site
.codex
weflow-web-offical
Insight

23
.gitleaks.toml Normal file
View 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
View File

@@ -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

View File

@@ -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,18 +36,18 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.deb``.tar.gz` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
## 快速开始
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
## 详细功能清单
当前版本已支持以下能力:
@@ -64,6 +55,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
| 功能模块 | 说明 |
|---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **消息防撤回** | 防止其他人发送的消息被撤回 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
@@ -88,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
完整接口文档:[点击查看](docs/HTTP-API.md)
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
@@ -103,7 +94,6 @@ npm install
# 3. 运行应用(开发模式)
npm run dev
```
## 致谢
@@ -115,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">

View File

@@ -1,6 +1,6 @@
# WeFlow HTTP API / Push 文档
WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
WeFlow 提供本地 HTTP API已支持GET 和 POST请求,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
## 启用方式
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、
- 基础地址:`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
**状态记忆**API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
## 鉴权规范
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
2. **Query 参数**: `?access_token=<您的Token>`SSE 长连接推荐此方式)
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
## 接口列表
- `GET /health`
- `GET /api/v1/health`
- `GET /api/v1/push/messages`
- `GET /api/v1/messages`
- `GET /api/v1/messages/new`
- `GET /api/v1/sessions`
- `GET /api/v1/contacts`
- `GET /api/v1/group-members`
- `GET /api/v1/media/*`
- `GET|POST /health`
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/messages/new`
- `GET|POST /api/v1/sessions`
- `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*`
---
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```
示例事件:
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -276,6 +290,8 @@ GET /api/v1/sessions
## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -325,6 +341,8 @@ GET /api/v1/contacts
## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求**
@@ -415,7 +433,125 @@ 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
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
@@ -456,24 +592,28 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
---
## 8. 使用示例
## 9. 使用示例
### PowerShell
```powershell
Invoke-RestMethod http://127.0.0.1:5031/health
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
```
### cURL
```bash
curl http://127.0.0.1:5031/health
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
# GET 带 Token Header
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
# POST 带 JSON Body
curl -X POST http://127.0.0.1:5031/api/v1/messages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"talker": "xxx@chatroom", "chatlab": true}'
```
### Python
@@ -482,24 +622,26 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
import requests
BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
messages = requests.get(
f"{BASE_URL}/api/v1/messages",
params={"talker": "xxx@chatroom", "limit": 50}
# POST 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json()
# GET 方式获取群成员
members = requests.get(
f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
headers=headers
).json()
print(messages)
print(members)
```
---
## 9. 注意事项
## 10. 注意事项
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
2. 使用前需要先在 WeFlow 中完成数据库连接。

View File

@@ -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 : [],

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}

View File

@@ -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) => {
@@ -225,11 +253,36 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
getMessage: (sessionId: string, localId: number) =>
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)
},
@@ -297,6 +383,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -409,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)
},
@@ -422,8 +524,197 @@ contextBridge.exposeInMainWorld('electronAPI', {
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),
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')
})

View 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()

File diff suppressed because it is too large Load Diff

View 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。
- 未转写成功的语音不可作为事实依据。

View File

@@ -0,0 +1,6 @@
你会收到 conversation_summary历史压缩摘要
使用方式:
- 默认把摘要作为历史背景,不逐字复述。
- 若摘要与最近消息冲突,以最近消息为准。
- 若用户追问很久之前的细节,优先重新调用工具检索,不依赖旧记忆。

View File

@@ -0,0 +1,8 @@
工具ai_fetch_message_briefs
何时用:
- 需要核对少量关键消息原文,避免全量展开。
调用建议:
- 只传必要 itemssessionId + localId每次少量<=20
- 默认 minimal需要上下文再用 standard/full。

View File

@@ -0,0 +1,9 @@
工具ai_query_session_candidates
何时用:
- 用户未明确具体会话,但给了关键词/关系词(如“老婆”“买车”)。
调用建议:
- 首次调用 detailLevel=minimal。
- 默认 limit 8~12避免拉太多候选。
- 当候选歧义较大时再升级 detailLevel=standard/full。

View File

@@ -0,0 +1,9 @@
工具ai_query_session_glimpse
何时用:
- 已确定候选会话,需要“先看一点”理解上下文。
Agent策略
- 每个候选会话先抽样 6~20 条,按时间顺序阅读。
- 不要只读一个会话就结束;优先覆盖多会话后再总结。
- 如果出现明显分歧场景(工作/家庭/感情)需主动向用户确认分析目标。

View File

@@ -0,0 +1,8 @@
工具ai_query_source_refs
何时用:
- 输出总结或分析后,用于来源说明与可解释卡片。
调用建议:
- 默认 minimal 即可,输出 range/session_count/message_count/db_refs。
- 只有排错或审计时再请求 full。

View File

@@ -0,0 +1,9 @@
工具ai_query_time_window_activity
何时用:
- 用户提到“今天凌晨/昨晚/某个时间段”的聊天分析。
Agent策略
- 第一步必须先扫时间窗活跃会话,不要直接下结论。
- 拿到活跃会话后,再调用 ai_query_session_glimpse 逐个会话抽样阅读。
- 若用户目标仍不清晰,先追问 1 个关键澄清问题再继续。

View File

@@ -0,0 +1,9 @@
工具ai_query_timeline
何时用:
- 回忆事件经过、梳理时间线、提取关键节点。
调用建议:
- 默认 detailLevel=minimal。
- 先小批次 limit40~120不够再分页 offset。
- 需要引用原文证据时,可搭配 ai_fetch_message_briefs。

View File

@@ -0,0 +1,9 @@
工具ai_query_top_contacts
何时用:
- 用户问“谁联系最密切”“谁聊得最多”“最常联系的是谁”。
调用建议:
- 该问题优先调用本工具,而不是先跑时间轴。
- 默认 detailLevel=minimallimit 5~10。
- 需要区分群聊时再设置 includeGroups=true。

View File

@@ -0,0 +1,8 @@
工具ai_query_topic_stats
何时用:
- 用户问“多少、占比、趋势、对比”。
调用建议:
- 仅在统计问题时调用,避免无谓聚合。
- 默认 detailLevel=minimal有统计追问再升到 standard/full。

View File

@@ -0,0 +1,8 @@
工具ai_list_voice_messages
何时用:
- 用户提到“语音里说了什么”。
调用建议:
- 第一步先拿 ID 清单,默认 detailLevel=minimal仅 IDs
- 如用户需要挑选依据,再用 standard/full 查看更多元数据。

View File

@@ -0,0 +1,9 @@
工具ai_transcribe_voice_messages
何时用:
- 已明确拿到语音 ID且用户需要读取语音内容。
调用建议:
- 必须显式传 ids 或 items。
- 单次控制在小批次(建议 <=5失败可重试。
- 转写成功后再参与总结;失败项单独标注,不混入结论。

View 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()

View 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()

View File

@@ -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)

View 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();

View 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

View File

@@ -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()

View File

@@ -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
@@ -52,12 +61,66 @@ interface ConfigSchema {
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
httpApiToken: string
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'])
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'])
@@ -114,13 +177,50 @@ export class ConfigService {
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
windowCloseBehavior: 'ask',
wordCloudExcludeWords: []
quoteLayout: 'quote-top',
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 = {
@@ -152,6 +252,7 @@ export class ConfigService {
}
}
this.migrateAuthFields()
this.migrateAiConfig()
}
// === 状态查询 ===
@@ -209,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]
@@ -242,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')
}
@@ -250,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)
@@ -588,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)
}
// === 迁移 ===
@@ -597,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')
@@ -649,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 {
@@ -660,11 +788,9 @@ export class ConfigService {
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
return false
}
// === 工具方法 ===

View File

@@ -93,6 +93,9 @@ class ContactExportService {
displayName: c.displayName,
remark: c.remark,
nickname: c.nickname,
alias: c.alias,
labels: Array.isArray(c.labels) ? c.labels : [],
detailDescription: c.detailDescription,
type: c.type
}))
}
@@ -103,12 +106,15 @@ class ContactExportService {
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
const rows = contacts.map(c => [
c.username || '',
c.displayName || '',
c.remark || '',
c.nickname || '',
c.alias || '',
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
c.detailDescription || '',
this.getTypeLabel(c.type)
])
@@ -137,9 +143,13 @@ class ContactExportService {
lines.push(`NICKNAME:${c.nickname}`)
}
// 备注
if (c.remark) {
lines.push(`NOTE:${c.remark}`)
const noteParts = [
c.remark ? String(c.remark) : '',
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
].filter(Boolean)
if (noteParts.length > 0) {
lines.push(`NOTE:${noteParts.join('\\n')}`)
}
// 微信ID

File diff suppressed because it is too large Load Diff

View File

@@ -93,27 +93,39 @@ export class DbPathService {
const possiblePaths: string[] = []
const home = homedir()
// macOS 微信路径(固定)
if (process.platform === 'darwin') {
// macOS 微信 4.0.5+ 新路径(优先检测)
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
// 匹配形如 2.0b4.0.9 的版本目录
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
possiblePaths.push(join(appSupportBase, entry))
}
}
} catch { }
}
// macOS 旧路径兜底
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
} else {
// Windows 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
}
for (const path of possiblePaths) {
if (existsSync(path)) {
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
if (!existsSync(path)) continue
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 检查是否有有效的账号目录,或本身就是账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
if (this.isAccountDir(path)) {
return { success: true, path }
}
}
@@ -295,6 +307,20 @@ export class DbPathService {
getDefaultPath(): string {
const home = homedir()
if (process.platform === 'darwin') {
// 优先返回 4.0.5+ 新路径
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
const candidate = join(appSupportBase, entry)
if (existsSync(candidate)) return candidate
}
}
} catch { }
}
// 旧版本路径兜底
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
}
return join(home, 'Documents', 'xwechat_files')

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo {
username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberAnalytics {
statistics: ChatStatistics
timeDistribution: Record<number, number>
commonPhrases?: Array<{ phrase: string; count: number }>
commonEmojis?: Array<{ emoji: string; count: number }>
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
@@ -257,34 +265,60 @@ class GroupAnalyticsService {
}
/**
* 从 DLL 获取群成员群昵称
* 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
*/
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new Map<string, string>()
try {
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
if (!dllResult.success || !dllResult.nicknames) {
return new Map<string, string>()
}
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>()
}
}
private normalizeGroupNicknameIdentity(value: string): string {
const raw = String(value || '').trim()
if (!raw) return ''
return raw.toLowerCase()
}
private buildTrustedGroupNicknameMap(
entries: Iterable<[string, string]>,
candidates: string[] = []
): Map<string, string> {
const candidateSet = new Set(
this.buildGroupNicknameIdCandidates(candidates)
.map((id) => this.normalizeGroupNicknameIdentity(id))
.filter(Boolean)
)
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of entries) {
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
if (!identity) continue
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
const slot = buckets.get(identity)
if (slot) {
slot.add(nickname)
} else {
buckets.set(identity, new Set([nickname]))
}
}
try {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (!result.success || !result.extBuffer) {
return nicknameMap
}
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return nicknameMap
const trusted = new Map<string, string>()
for (const [identity, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(identity, Array.from(nicknameSet)[0])
}
return trusted
}
private mergeGroupNicknameEntries(
@@ -475,6 +509,16 @@ class GroupAnalyticsService {
return Array.from(set)
}
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
}
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
@@ -663,30 +707,23 @@ class GroupAnalyticsService {
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
if (exact) return exact
}
for (const id of idCandidates) {
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknames.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
const normalizedId = this.normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (matched === 1 && found) return found
if (resolved !== candidateNickname) return ''
}
return ''
return resolved
}
private sanitizeWorksheetName(name: string): string {
@@ -768,7 +805,12 @@ class GroupAnalyticsService {
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
return myWxid
}
const candidates = [
row.sender_username,
row.senderUsername,
@@ -791,13 +833,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue
}
}
// Fallback: fast extract from raw content to avoid full parse
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
if (rawContent) {
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
if (Array.isArray(mapped) && mapped.length > 0) {
const msg = mapped[0]
if (!msg.localType) {
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
}
if (!msg.createTime) {
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
}
return msg
}
return null
} catch {
return null
}
@@ -852,7 +914,7 @@ class GroupAnalyticsService {
if (rows.length === 0) break
for (const row of rows) {
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -958,7 +1020,7 @@ class GroupAnalyticsService {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -1438,6 +1500,154 @@ class GroupAnalyticsService {
}
}
async getGroupMemberAnalytics(
chatroomId: string,
memberUsername: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
const batchSize = 10000
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
const cursor = cursorResult.cursor
const stats: ChatStatistics = {
totalMessages: 0,
textMessages: 0,
imageMessages: 0,
voiceMessages: 0,
videoMessages: 0,
emojiMessages: 0,
otherMessages: 0,
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
receivedMessages: 0, // No meaning here
firstMessageTime: null,
lastMessageTime: null,
activeDays: 0,
messageTypeCounts: {}
}
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
const dailySet = new Set<string>()
const textTypes = [1, 244813135921]
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取分析数据失败' }
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
for (const row of rows) {
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
if (isSend) {
senderFromRow = myWxid
}
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
continue
}
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
if (content) {
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
}
stats.totalMessages++
if (textTypes.includes(msgType)) {
stats.textMessages++
if (content) {
const text = content.trim()
if (text && text.length <= 20) {
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
}
const emojiMatches = text.match(/\[.*?\]/g)
if (emojiMatches) {
for (const em of emojiMatches) {
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
}
}
}
}
else if (msgType === 3) stats.imageMessages++
else if (msgType === 34) stats.voiceMessages++
else if (msgType === 43) stats.videoMessages++
else if (msgType === 47) stats.emojiMessages++
else stats.otherMessages++
stats.sentMessages++
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
if (createTime > 0) {
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
const d = new Date(createTime * 1000)
const hour = d.getHours()
hourlyDistribution[hour]++
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
stats.activeDays = dailySet.size
const commonPhrases = Array.from(phraseCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([phrase, count]) => ({ phrase, count }))
const commonEmojis = Array.from(emojiCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([emoji, count]) => ({ emoji, count }))
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,

View File

@@ -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 {
@@ -101,6 +103,7 @@ class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +117,13 @@ class HttpService {
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
this.host = host
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
@@ -153,10 +157,10 @@ class HttpService {
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.server.listen(this.port, this.host, () => {
this.running = true
this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
resolve({ success: true, port: this.port })
})
})
@@ -225,7 +229,7 @@ class HttpService {
}
getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
return `http://${this.host}:${this.port}/api/v1/push/messages`
}
broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -246,49 +250,167 @@ class HttpService {
}
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
async autoStart(): Promise<void> {
const enabled = this.configService.get('httpApiEnabled')
if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try {
await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
console.error('[HttpService] Auto-start failed:', err)
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 解析 POST 请求的 JSON Body
*/
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 = ''
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))
} catch {
resolve({})
}
})
req.on('error', () => resolve({}))
})
}
/**
* 鉴权拦截器
*/
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) {
// 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 (this.safeEqual(token, expectedToken)) return true
}
const queryToken = url.searchParams.get('access_token')
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
const bodyToken = body['access_token']
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
}
/**
* 处理 HTTP 请求 (重构后)
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 仅允许本地来源的跨域请求
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') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname
try {
const bodyParams = await this.parseBody(req)
for (const [key, value] of Object.entries(bodyParams)) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, String(value))
}
}
if (pathname !== '/health' && pathname !== '/api/v1/health') {
if (!this.verifyToken(req, url, bodyParams)) {
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
return
}
}
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
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 {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => {
@@ -334,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')
@@ -490,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) {
@@ -721,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')
}
@@ -895,7 +1339,7 @@ class HttpService {
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
}
@@ -1017,13 +1461,31 @@ class HttpService {
}
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return ''
const cleaned = this.normalizeAccountId(sender)
return groupNicknamesMap.get(sender)
|| groupNicknamesMap.get(sender.toLowerCase())
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase())
|| ''
const key = String(sender || '').trim().toLowerCase()
if (!key) return ''
return groupNicknamesMap.get(key) || ''
}
private buildTrustedGroupNicknameMap(nicknames: Record<string, string>): Map<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const trusted = new Map<string, string>()
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted.set(memberId, Array.from(nicknameSet)[0])
}
return trusted
}
private resolveChatLabSenderInfo(
@@ -1094,21 +1556,7 @@ class HttpService {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map()
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
groupNicknamesMap = this.buildTrustedGroupNicknameMap(result.nicknames)
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
@@ -1161,7 +1609,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
})
@@ -1378,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}`)
}
/**
* 发送错误响应
*/
@@ -1389,4 +1842,3 @@ class HttpService {
}
export const httpService = new HttpService()

View File

@@ -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[] = []

View File

@@ -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,

View 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 使用白名单 sessionIdgenerateInsightForSession 内部会从上下文里获取真实名称
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()

View File

@@ -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)

View File

@@ -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 }
}
}
}

View File

@@ -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,23 +403,75 @@ 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',
'set outText to do shell script cmd with administrator privileges',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
@@ -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 以管理员权限运行 helperSIP 下 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 {
@@ -935,10 +1031,17 @@ export class KeyServiceMac {
private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
if (!normalized) return null
// 旧路径xwechat_files
const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker)
if (markerIdx < 0) return null
return normalized.slice(0, markerIdx + marker.length)
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
// 新路径(微信 4.0.5+Application Support/com.tencent.xinWeChat/2.0b4.0.9
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
if (newMarkerMatch) return newMarkerMatch[1]
return null
}
private pushAccountIdCandidates(candidates: string[], value?: string): void {
@@ -1096,6 +1199,16 @@ export class KeyServiceMac {
candidates.add(`${base}/app_data/net/kvcomm`)
}
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
if (newMarkerMatch) {
const versionBase = newMarkerMatch[1]
candidates.add(`${versionBase}/net/kvcomm`)
// 上级目录也尝试
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
candidates.add(`${parentBase}/net/kvcomm`)
}
let cursor = accountPath
for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm'))

View 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");
}

View File

@@ -304,11 +304,8 @@ class MessagePushService {
}
const groupNicknames = await this.getGroupNicknames(chatroomId)
const normalizedSender = this.normalizeAccountId(senderUsername)
const nickname = groupNicknames[senderUsername]
|| groupNicknames[senderUsername.toLowerCase()]
|| groupNicknames[normalizedSender]
|| groupNicknames[normalizedSender.toLowerCase()]
const senderKey = senderUsername.toLowerCase()
const nickname = groupNicknames[senderKey]
if (nickname) {
return nickname
@@ -328,22 +325,33 @@ class MessagePushService {
}
const result = await wcdbService.getGroupNicknames(cacheKey)
const nicknames = result.success && result.nicknames ? result.nicknames : {}
const nicknames = result.success && result.nicknames
? this.sanitizeGroupNicknames(result.nicknames)
: {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match ? match[1] : trimmed
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
const buckets = new Map<string, Set<string>>()
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
const memberId = String(memberIdRaw || '').trim().toLowerCase()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
const slot = buckets.get(memberId)
if (slot) {
slot.add(nickname)
} else {
buckets.set(memberId, new Set([nickname]))
}
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
const trusted: Record<string, string> = {}
for (const [memberId, nicknameSet] of buckets.entries()) {
if (nicknameSet.size !== 1) continue
trusted[memberId] = Array.from(nicknameSet)[0]
}
return trusted
}
private isRecentMessage(messageKey: string): boolean {

View File

@@ -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']) {

View File

@@ -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 }

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'win32') {
// 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(';')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env

File diff suppressed because it is too large Load Diff

View File

@@ -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')

View File

@@ -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

View File

@@ -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 中处理 (导航)
}

4135
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "2.1.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,19 +52,31 @@
"@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",
"afterPack": "scripts/afterPack-sign-manifest.cjs",
"afterSign": "scripts/afterPack-sign-manifest.cjs",
"publish": {
"provider": "github",
"owner": "hicccc77",
@@ -86,24 +98,47 @@
"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",
"target": [
"appimage",
"deb",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux"
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/installer/linux/install.sh",
"to": "install.sh"
}
]
},
"nsis": {
"oneClick": false,
@@ -155,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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -e
APP_NAME="weflow"
APP_EXEC="weflow"
OPT_DIR="/opt/$APP_NAME"
BIN_LINK="/usr/bin/$APP_NAME"
DESKTOP_DIR="/usr/share/applications"
ICON_DIR="/usr/share/pixmaps"
if [ "$EUID" -ne 0 ]; then
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
exit 1
fi
echo "🚀 开始安装 $APP_NAME..."
echo "📦 正在复制文件到 $OPT_DIR..."
rm -rf "$OPT_DIR"
mkdir -p "$OPT_DIR"
cp -r ./* "$OPT_DIR/"
chmod -R 755 "$OPT_DIR"
chmod +x "$OPT_DIR/$APP_EXEC"
echo "🔗 正在创建软链接 $BIN_LINK..."
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
echo "📝 正在创建桌面快捷方式..."
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
[Desktop Entry]
Name=WeFlow
Exec=$OPT_DIR/$APP_EXEC %U
Terminal=false
Type=Application
Icon=$APP_NAME
StartupWMClass=WeFlow
Comment=A local WeChat database decryption and analysis tool
Categories=Utility;
EOF
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
echo "🖼️ 正在安装图标..."
if [ -f "$OPT_DIR/resources/icon.png" ]; then
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
elif [ -f "$OPT_DIR/icon.png" ]; then
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
else
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
fi
if command -v update-desktop-database >/dev/null 2>&1; then
echo "🔄 更新桌面数据库..."
update-desktop-database "$DESKTOP_DIR"
fi
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow或者在终端输入 'weflow' 启动。"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,289 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const MANIFEST_NAME = '.wf_manifest.json';
const SIGNATURE_NAME = '.wf_manifest.sig';
const MODULE_FILENAME = {
win32: 'wcdb_api.dll',
darwin: 'libwcdb_api.dylib',
linux: 'libwcdb_api.so',
};
function readTextIfExists(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
function loadEnvFile(projectDir, fileName) {
const envPath = path.join(projectDir, fileName);
const content = readTextIfExists(envPath);
if (!content) return false;
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (!key || process.env[key] !== undefined) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key] = value;
}
return true;
}
function ensureSigningEnv() {
const projectDir = process.cwd();
if (!process.env.WF_SIGN_PRIVATE_KEY) {
loadEnvFile(projectDir, '.env.local');
loadEnvFile(projectDir, '.env');
}
const keyB64 = (process.env.WF_SIGN_PRIVATE_KEY || '').trim();
const required = (process.env.WF_SIGNING_REQUIRED || '').trim() === '1';
if (!keyB64) {
if (required) {
throw new Error(
'WF_SIGN_PRIVATE_KEY is missing (WF_SIGNING_REQUIRED=1). ' +
'Set it in CI Secret or .env.local for local build.',
);
}
return null;
}
return keyB64;
}
function getPlatform(context) {
const raw = (
context?.electronPlatformName ||
context?.packager?.platform?.name ||
process.platform
);
return normalizePlatformTag(raw);
}
function normalizePlatformTag(rawPlatform) {
const p = String(rawPlatform || '').toLowerCase();
if (p === 'darwin' || p === 'mac' || p === 'macos' || p === 'osx') return 'darwin';
if (p === 'win32' || p === 'win' || p === 'windows') return 'win32';
if (p === 'linux') return 'linux';
return p || process.platform;
}
function getProductFilename(context) {
return (
context?.packager?.appInfo?.productFilename ||
context?.packager?.config?.productName ||
'WeFlow'
);
}
function resolveMacAppBundle(appOutDir, productFilename) {
const candidates = [];
if (String(appOutDir).toLowerCase().endsWith('.app')) {
candidates.push(appOutDir);
} else {
candidates.push(path.join(appOutDir, `${productFilename}.app`));
if (fs.existsSync(appOutDir) && fs.statSync(appOutDir).isDirectory()) {
const appDirs = fs
.readdirSync(appOutDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.toLowerCase().endsWith('.app'))
.map((e) => path.join(appOutDir, e.name));
candidates.push(...appDirs);
}
}
for (const bundleDir of candidates) {
const resourcesPath = path.join(bundleDir, 'Contents', 'Resources');
if (fs.existsSync(resourcesPath) && fs.statSync(resourcesPath).isDirectory()) {
return bundleDir;
}
}
return null;
}
function getResourcesDir(appOutDir, platform, productFilename) {
if (platform === 'darwin') {
const bundleDir = resolveMacAppBundle(appOutDir, productFilename);
if (!bundleDir) return path.join(appOutDir, 'resources');
return path.join(bundleDir, 'Contents', 'Resources');
}
return path.join(appOutDir, 'resources');
}
function normalizeRel(baseDir, filePath) {
return path.relative(baseDir, filePath).split(path.sep).join('/');
}
function sha256FileHex(filePath) {
const data = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(data).digest('hex');
}
function findFirstExisting(paths) {
for (const p of paths) {
if (p && fs.existsSync(p) && fs.statSync(p).isFile()) return p;
}
return null;
}
function findExecutablePath({ appOutDir, platform, productFilename, executableName }) {
if (platform === 'win32') {
return findFirstExisting([
path.join(appOutDir, `${productFilename}.exe`),
path.join(appOutDir, `${executableName || ''}.exe`),
]);
}
if (platform === 'darwin') {
const bundleDir = resolveMacAppBundle(appOutDir, productFilename) || appOutDir;
const macOsDir = path.join(bundleDir, 'Contents', 'MacOS');
const preferred = findFirstExisting([path.join(macOsDir, productFilename)]);
if (preferred) return preferred;
if (!fs.existsSync(macOsDir)) return null;
const files = fs
.readdirSync(macOsDir)
.map((name) => path.join(macOsDir, name))
.filter((p) => fs.statSync(p).isFile());
return files[0] || null;
}
return findFirstExisting([
path.join(appOutDir, executableName || ''),
path.join(appOutDir, productFilename),
path.join(appOutDir, productFilename.toLowerCase()),
]);
}
function findByBasenameRecursive(rootDir, basename) {
if (!fs.existsSync(rootDir)) return null;
const stack = [rootDir];
while (stack.length > 0) {
const dir = stack.pop();
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && entry.name.toLowerCase() === basename.toLowerCase()) {
return full;
}
}
}
return null;
}
function getModulePath(resourcesDir, appOutDir, platform) {
const filename = MODULE_FILENAME[platform] || MODULE_FILENAME[process.platform];
if (!filename) return null;
const direct = findFirstExisting([
path.join(resourcesDir, 'resources', filename),
path.join(resourcesDir, filename),
]);
if (direct) return direct;
const inResources = findByBasenameRecursive(resourcesDir, filename);
if (inResources) return inResources;
return findByBasenameRecursive(appOutDir, filename);
}
function signDetachedEd25519(payloadUtf8, privateKeyDerB64) {
const privateKeyDer = Buffer.from(privateKeyDerB64, 'base64');
const keyObject = crypto.createPrivateKey({
key: privateKeyDer,
format: 'der',
type: 'pkcs8',
});
return crypto.sign(null, Buffer.from(payloadUtf8, 'utf8'), keyObject);
}
module.exports = async function afterPack(context) {
const privateKeyDerB64 = ensureSigningEnv();
if (!privateKeyDerB64) {
console.log('[wf-sign] skip: WF_SIGN_PRIVATE_KEY not provided and signing not required.');
return;
}
const appOutDir = context?.appOutDir;
if (!appOutDir || !fs.existsSync(appOutDir)) {
throw new Error(`[wf-sign] invalid appOutDir: ${String(appOutDir)}`);
}
const platform = String(getPlatform(context)).toLowerCase();
const productFilename = getProductFilename(context);
const executableName = context?.packager?.config?.linux?.executableName || '';
const resourcesDir = getResourcesDir(appOutDir, platform, productFilename);
if (!fs.existsSync(resourcesDir)) {
throw new Error(
`[wf-sign] resources directory not found: ${resourcesDir}; platform=${platform}; appOutDir=${appOutDir}`,
);
}
const exePath = findExecutablePath({
appOutDir,
platform,
productFilename,
executableName,
});
if (!exePath) {
throw new Error(
`[wf-sign] executable not found. platform=${platform}, appOutDir=${appOutDir}, productFilename=${productFilename}`,
);
}
const modulePath = getModulePath(resourcesDir, appOutDir, platform);
if (!modulePath) {
throw new Error(
`[wf-sign] ${MODULE_FILENAME[platform] || 'wcdb_api'} not found under resources: ${resourcesDir}`,
);
}
const manifest = {
schema: 1,
platform,
version: context?.packager?.appInfo?.version || '',
generatedAt: new Date().toISOString(),
targets: [
{
id: 'exe',
path: normalizeRel(resourcesDir, exePath),
sha256: sha256FileHex(exePath),
},
{
id: 'module',
path: normalizeRel(resourcesDir, modulePath),
sha256: sha256FileHex(modulePath),
},
],
};
const payload = `${JSON.stringify(manifest, null, 2)}\n`;
const signature = signDetachedEd25519(payload, privateKeyDerB64).toString('base64');
const manifestPath = path.join(resourcesDir, MANIFEST_NAME);
const signaturePath = path.join(resourcesDir, SIGNATURE_NAME);
fs.writeFileSync(manifestPath, payload, 'utf8');
fs.writeFileSync(signaturePath, `${signature}\n`, 'utf8');
console.log(`[wf-sign] manifest: ${manifestPath}`);
console.log(`[wf-sign] signature: ${signaturePath}`);
console.log(`[wf-sign] exe: ${manifest.targets[0].path}`);
console.log(`[wf-sign] exe.sha256: ${manifest.targets[0].sha256}`);
console.log(`[wf-sign] module: ${manifest.targets[1].path}`);
console.log(`[wf-sign] module.sha256: ${manifest.targets[1].sha256}`);
};

View File

@@ -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>
&nbsp;·&nbsp;
<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>

View File

@@ -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 @@
}
}
}
}
}

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More