Compare commits

...

51 Commits

Author SHA1 Message Date
dependabot[bot]
69e7db63cc chore(deps): bump koffi from 2.15.2 to 2.15.6
Bumps [koffi](https://github.com/Koromix/koffi) from 2.15.2 to 2.15.6.
- [Commits](https://github.com/Koromix/koffi/commits)

---
updated-dependencies:
- dependency-name: koffi
  dependency-version: 2.15.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 23:17:02 +00:00
cc
902d2c9c74 Merge pull request #666 from xunchahaha/dev
Dev
2026-04-07 23:34:44 +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
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
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
Jason
b8cd9a8c38 Merge branch 'hicccc77:main' into main 2026-04-06 13:13:15 +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
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
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
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
49 changed files with 2383 additions and 608 deletions

View File

@@ -1,5 +1,8 @@
name: Security Scan name: Security Scan
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on: on:
schedule: schedule:
- cron: '0 2 * * *' # 每天 UTC 02:00 - cron: '0 2 * * *' # 每天 UTC 02:00
@@ -24,15 +27,15 @@ jobs:
steps: steps:
- name: Checkout ${{ matrix.branch }} - name: Checkout ${{ matrix.branch }}
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
ref: ${{ matrix.branch }} ref: ${{ matrix.branch }}
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '24'
cache: 'npm' # 使用 npm 缓存加速 cache: 'npm' # 使用 npm 缓存加速
- name: Install dependencies - name: Install dependencies
@@ -71,10 +74,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 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 - name: Run npm audit on all branches
run: | run: |
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
@@ -84,4 +93,4 @@ jobs:
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true 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 npm audit --audit-level=moderate 2>/dev/null || true
done done
continue-on-error: true continue-on-error: true

4
.gitignore vendored
View File

@@ -56,6 +56,8 @@ Thumbs.db
*.aps *.aps
wcdb/ wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/ xkey/
server/ server/
*info *info
@@ -73,4 +75,4 @@ pnpm-lock.yaml
wechat-research-site wechat-research-site
.codex .codex
weflow-web-offical weflow-web-offical
Insight Insight

View File

@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
function stripDatVariantSuffix(base: string): string { function stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase() 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) { for (const suffix of suffixes) {
if (lower.endsWith(suffix)) { if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length) return lower.slice(0, -suffix.length)
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
const lower = fileName.toLowerCase() const lower = fileName.toLowerCase()
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 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 (!hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (isThumbnailDat(lower)) return 100 if (isThumbnailDat(lower)) return 100
return 350 return 350

View File

@@ -30,6 +30,7 @@ import { cloudControlService } from './services/cloudControlService'
import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow' import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow'
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService' import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
// 配置自动更新 // 配置自动更新
@@ -1621,6 +1622,19 @@ function registerIpcHandlers() {
return result return result
}) })
// AI 见解
ipcMain.handle('insight:testConnection', async () => {
return insightService.testConnection()
})
ipcMain.handle('insight:getTodayStats', async () => {
return insightService.getTodayStats()
})
ipcMain.handle('insight:triggerTest', async () => {
return insightService.triggerTest()
})
ipcMain.handle('config:clear', async () => { ipcMain.handle('config:clear', async () => {
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
const result = setSystemLaunchAtStartup(false) const result = setSystemLaunchAtStartup(false)
@@ -2558,7 +2572,13 @@ function registerIpcHandlers() {
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload) return imageDecryptService.decryptImage(payload)
}) })
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => { ipcMain.handle('image:resolveCache', async (_, payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => {
return imageDecryptService.resolveCachedImage(payload) return imageDecryptService.resolveCachedImage(payload)
}) })
ipcMain.handle( ipcMain.handle(
@@ -2566,13 +2586,14 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => { ) => {
const list = Array.isArray(payloads) ? payloads : [] const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => { const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({ return imageDecryptService.resolveCachedImage({
...payload, ...payload,
disableUpdateCheck: options?.disableUpdateCheck === true disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false
}) })
})) }))
return { success: true, rows } return { success: true, rows }
@@ -2583,7 +2604,7 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => { ) => {
imagePreloadService.enqueue(payloads || [], options) imagePreloadService.enqueue(payloads || [], options)
return true return true
@@ -3478,8 +3499,10 @@ app.whenReady().then(async () => {
registerIpcHandlers() registerIpcHandlers()
chatService.addDbMonitorListener((type, json) => { chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json) messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json)
}) })
messagePushService.start() messagePushService.start()
insightService.start()
await delay(200) await delay(200)
// 检查配置状态 // 检查配置状态
@@ -3600,6 +3623,7 @@ app.on('before-quit', async () => {
if (tray) { try { tray.destroy() } catch {} tray = null } if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。 // 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。
destroyNotificationWindow() destroyNotificationWindow()
insightService.stop()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留 // 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => { const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout') console.warn('[App] Force exit after timeout')

View File

@@ -266,15 +266,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload), ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload), ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options), ) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
@@ -492,5 +498,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host), start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'), stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status') status: () => ipcRenderer.invoke('http:status')
},
// AI 见解
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
} }
}) })

View File

@@ -69,10 +69,34 @@ interface ConfigSchema {
quoteLayout: 'quote-top' | 'quote-bottom' quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[] wordCloudExcludeWords: string[]
exportWriteLayout: 'A' | 'B' | 'C' exportWriteLayout: 'A' | 'B' | 'C'
// AI 见解
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
} }
// 需要 safeStorage 加密的字段(普通模式) // 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken']) const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey']) const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -142,7 +166,22 @@ export class ConfigService {
windowCloseBehavior: 'ask', windowCloseBehavior: 'ask',
quoteLayout: 'quote-top', quoteLayout: 'quote-top',
wordCloudExcludeWords: [], wordCloudExcludeWords: [],
exportWriteLayout: 'A' exportWriteLayout: 'A',
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: ''
} }
const storeOptions: any = { const storeOptions: any = {
@@ -690,7 +729,7 @@ export class ConfigService {
// === 工具方法 === // === 工具方法 ===
/** /**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局 * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<EFBFBD><EFBFBD>
*/ */
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid') const wxid = this.get('myWxid')

View File

@@ -63,6 +63,7 @@ type CachedImagePayload = {
imageDatName?: string imageDatName?: string
preferFilePath?: boolean preferFilePath?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean
} }
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
@@ -116,7 +117,9 @@ export class ImageDecryptService {
} }
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> { async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed() if (payload.allowCacheIndex !== false) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload) const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0] const cacheKey = cacheKeys[0]
if (!cacheKey) { if (!cacheKey) {
@@ -673,41 +676,53 @@ export class ImageDecryptService {
return null return null
} }
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) const searchNames = Array.from(
if (!allowThumbnail) { new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
return null )
} if (searchNames.length === 0) return null
if (!imageDatName) return null
if (!skipResolvedCache) { if (!skipResolvedCache) {
const cached = this.resolvedCache.get(imageDatName) for (const searchName of searchNames) {
if (cached && existsSync(cached)) { const cached = this.resolvedCache.get(searchName)
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
// 缓存的是缩略图,尝试找高清图 if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
const hdPath = this.findHdVariantInSameDir(preferred) // 缓存的是缩略图,尝试找高清图
if (hdPath) return hdPath const hdPath = this.findHdVariantInSameDir(preferred)
if (hdPath) return hdPath
}
} }
} }
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) for (const searchName of searchNames) {
if (datPath) { const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath }) if (datPath) {
this.resolvedCache.set(imageDatName, datPath) this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
this.cacheDatPath(accountDir, imageDatName, datPath) if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
return datPath if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
} this.cacheDatPath(accountDir, searchName, datPath)
const normalized = this.normalizeDatBase(imageDatName) if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
if (normalized !== imageDatName.toLowerCase()) { if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) return datPath
if (normalizedPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
this.resolvedCache.set(imageDatName, normalizedPath)
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
return normalizedPath
} }
} }
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 return null
} }
@@ -1042,7 +1057,7 @@ export class ImageDecryptService {
private stripDatVariantSuffix(base: string): string { private stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase() 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) { for (const suffix of suffixes) {
if (lower.endsWith(suffix)) { if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length) return lower.slice(0, -suffix.length)
@@ -1058,8 +1073,10 @@ export class ImageDecryptService {
const lower = name.toLowerCase() const lower = name.toLowerCase()
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower 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('_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 (!this.hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (this.isThumbnailDat(lower)) return 100 if (this.isThumbnailDat(lower)) return 100
return 350 return 350
@@ -1070,9 +1087,13 @@ export class ImageDecryptService {
const names = [ const names = [
`${baseName}_h.dat`, `${baseName}_h.dat`,
`${baseName}.h.dat`, `${baseName}.h.dat`,
`${baseName}.dat`,
`${baseName}_hd.dat`, `${baseName}_hd.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`,
`${baseName}.c.dat` `${baseName}.c.dat`
] ]

View File

@@ -8,11 +8,13 @@ type PreloadImagePayload = {
type PreloadOptions = { type PreloadOptions = {
allowDecrypt?: boolean allowDecrypt?: boolean
allowCacheIndex?: boolean
} }
type PreloadTask = PreloadImagePayload & { type PreloadTask = PreloadImagePayload & {
key: string key: string
allowDecrypt: boolean allowDecrypt: boolean
allowCacheIndex: boolean
} }
export class ImagePreloadService { export class ImagePreloadService {
@@ -27,6 +29,7 @@ export class ImagePreloadService {
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== false
for (const payload of payloads) { for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName const cacheKey = payload.imageMd5 || payload.imageDatName
@@ -34,7 +37,7 @@ export class ImagePreloadService {
const key = `${payload.sessionId || 'unknown'}|${cacheKey}` const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue if (this.pending.has(key)) continue
this.pending.add(key) this.pending.add(key)
this.queue.push({ ...payload, key, allowDecrypt }) this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
} }
this.processQueue() this.processQueue()
} }
@@ -71,7 +74,8 @@ export class ImagePreloadService {
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName, imageDatName: task.imageDatName,
disableUpdateCheck: !task.allowDecrypt disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex
}) })
if (cached.success) return if (cached.success) return
if (!task.allowDecrypt) return if (!task.allowDecrypt) return

View File

@@ -0,0 +1,829 @@
/**
* 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 fs from 'fs'
import path from 'path'
import { URL } from 'url'
import { app, 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
// ─── 类型 ────────────────────────────────────────────────────────────────────
interface TodayTriggerRecord {
/** 该会话今日触发的时间戳列表(毫秒) */
timestamps: number[]
}
// ─── 桌面日志 ─────────────────────────────────────────────────────────────────
/**
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
*/
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
const now = new Date()
const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false })
const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n`
// 同步到 console
if (level === 'ERROR' || level === 'WARN') {
console.warn(`[InsightService] ${message}`)
} else {
console.log(`[InsightService] ${message}`)
}
// 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环
try {
const desktopPath = app.getPath('desktop')
const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`)
fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ })
} catch {
// getPath 失败时静默处理
}
}
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
/**
* 绝对拼接 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
insightLog('INFO', '已启动')
this.scheduleSilenceScan()
}
stop(): void {
this.started = false
this.dbConnected = false
this.sessionCache = null
this.sessionCacheAt = 0
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
}
insightLog('INFO', '已停止')
}
/**
* 由 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)
}
/**
* 测<><E6B58B><EFBFBD> API 连接,返回 { success, message }。
* 供设置页"测试连接"按钮调用。
*/
async testConnection(): Promise<{ success: boolean; message: string }> {
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
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 = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
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
}
// ── 私有方法 ────────────────────────────────────────────────────────────────
private isEnabled(): boolean {
return this.config.get('aiInsightEnabled') === true
}
/**
* 判断某个会话是否允许触发见解。
* 若白名单未启用,则所有私聊会话均允许;
* 若白名单已启用,则只有在白名单中的会话才允许。
*/
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 中告知模<E79FA5><E6A8A1><EFBFBD>全局上下文。
*/
private getTodayTotalTriggerCount(): number {
this.resetIfNewDay()
let total = 0
for (const record of this.todayTriggers.values()) {
total += record.timestamps.length
}
return total
}
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
private scheduleSilenceScan(): void {
// 等待扫描完成后再安排下一次,避免并发堆积
const scheduleNext = () => {
if (!this.started) 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()) {
insightLog('INFO', '沉默扫描AI 见解未启用,跳过')
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) {
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
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
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 ─────────────<E29480><E29480><EFBFBD>───────────────────────────────<E29480><E29480><EFBFBD>────────────
// 今日触发统计(让模型具备时间与克制感)
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
}
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 { private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DLL_PATH) { if (process.env.WX_KEY_DLL_PATH) {
@@ -68,11 +69,20 @@ export class KeyService {
} }
if (isPackaged) { 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, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
} }

View File

@@ -25,13 +25,23 @@ export class KeyServiceLinux {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) { 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, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else { } 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(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')) candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
} }
for (const p of candidates) { for (const p of candidates) {

View File

@@ -27,6 +27,7 @@ export class KeyServiceMac {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) { if (process.env.WX_KEY_HELPER_PATH) {
@@ -34,12 +35,21 @@ export class KeyServiceMac {
} }
if (isPackaged) { 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, 'resources', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'xkey_helper')) candidates.push(join(process.resourcesPath, 'xkey_helper'))
} else { } else {
const cwd = process.cwd() 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, 'resources', 'xkey_helper'))
candidates.push(join(cwd, 'Xkey', 'build', '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')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
} }
@@ -52,14 +62,24 @@ export class KeyServiceMac {
private getImageScanHelperPath(): string { private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (isPackaged) { 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, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
} }
@@ -72,6 +92,7 @@ export class KeyServiceMac {
private getDylibPath(): string { private getDylibPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DYLIB_PATH) { if (process.env.WX_KEY_DYLIB_PATH) {
@@ -79,11 +100,20 @@ export class KeyServiceMac {
} }
if (isPackaged) { 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, 'resources', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
} }

View File

@@ -1,12 +1,8 @@
import dbus from "dbus-native";
import https from "https"; import https from "https";
import http, { IncomingMessage } from "http"; import http, { IncomingMessage } from "http";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { join } from "path"; import { join } from "path";
import { app } from "electron"; import { app, Notification } from "electron";
const BUS_NAME = "org.freedesktop.Notifications";
const OBJECT_PATH = "/org/freedesktop/Notifications";
export interface LinuxNotificationData { export interface LinuxNotificationData {
sessionId?: string; sessionId?: string;
@@ -18,26 +14,29 @@ export interface LinuxNotificationData {
type NotificationCallback = (sessionId: string) => void; type NotificationCallback = (sessionId: string) => void;
let sessionBus: dbus.DBusConnection | null = null;
let notificationCallbacks: NotificationCallback[] = []; let notificationCallbacks: NotificationCallback[] = [];
let pendingNotifications: Map<number, LinuxNotificationData> = new Map(); let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
// 头像缓存url->localFilePath // 头像缓存url->localFilePath
const avatarCache: Map<string, string> = new Map(); const avatarCache: Map<string, string> = new Map();
// 缓存目录 // 缓存目录
let avatarCacheDir: string | null = null; let avatarCacheDir: string | null = null;
async function getSessionBus(): Promise<dbus.DBusConnection> { function nextNotificationId(): number {
if (!sessionBus) { const id = notificationCounter;
sessionBus = dbus.sessionBus(); notificationCounter += 1;
return id;
}
// 挂载底层socket的error事件防止掉线即可 function clearNotificationState(notificationId: number): void {
sessionBus.connection.on("error", (err: Error) => { activeNotifications.delete(notificationId);
console.error("[LinuxNotification] D-Bus connection error:", err); const timer = closeTimers.get(notificationId);
sessionBus = null; // 报错清理死对象 if (timer) {
}); clearTimeout(timer);
closeTimers.delete(notificationId);
} }
return sessionBus;
} }
// 确保缓存目录存在 // 确保缓存目录存在
@@ -125,66 +124,76 @@ async function downloadAvatarToLocal(url: string): Promise<string | null> {
} }
} }
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( export async function showLinuxNotification(
data: LinuxNotificationData, data: LinuxNotificationData,
): Promise<number | null> { ): Promise<number | null> {
if (process.platform !== "linux") {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return null;
}
try { try {
const bus = await getSessionBus(); let iconPath: string | undefined;
const appName = "WeFlow";
const replaceId = 0;
const expireTimeout = data.expireTimeout ?? 5000;
// 处理头像下载到本地或使用URL
let appIcon = "";
let hints: any[] = [];
if (data.avatarUrl) { if (data.avatarUrl) {
// 优先尝试下载到本地 iconPath = (await downloadAvatarToLocal(data.avatarUrl)) || undefined;
const localPath = await downloadAvatarToLocal(data.avatarUrl);
if (localPath) {
hints = [["image-path", ["s", localPath]]];
}
} }
return new Promise((resolve, reject) => { const notification = new Notification({
bus.invoke( title: data.title,
{ body: data.content,
destination: BUS_NAME, icon: iconPath,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "Notify",
signature: "susssasa{sv}i",
body: [
appName,
replaceId,
appIcon,
data.title,
data.content,
["default", "打开"], // 提供default action否则系统不会抛出点击事件
hints,
// [], // 传空数组以避开a{sv}变体的序列化崩溃有pendingNotifications映射维护保证不出错
expireTimeout,
],
},
(err: Error | null, result: any) => {
if (err) {
console.error("[LinuxNotification] Notify error:", err);
reject(err);
return;
}
const notificationId =
typeof result === "number" ? result : result[0];
if (data.sessionId) {
// 依赖Map实现点击追踪没有使用D-Bus hints
pendingNotifications.set(notificationId, data);
}
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`,
);
resolve(notificationId);
},
);
}); });
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) { } catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error); console.error("[LinuxNotification] Failed to show notification:", error);
return null; return null;
@@ -194,59 +203,22 @@ export async function showLinuxNotification(
export async function closeLinuxNotification( export async function closeLinuxNotification(
notificationId: number, notificationId: number,
): Promise<void> { ): Promise<void> {
try { const notification = activeNotifications.get(notificationId);
const bus = await getSessionBus(); if (!notification) return;
return new Promise((resolve, reject) => { notification.close();
bus.invoke( clearNotificationState(notificationId);
{
destination: BUS_NAME,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "CloseNotification",
signature: "u",
body: [notificationId],
},
(err: Error | null) => {
if (err) {
console.error("[LinuxNotification] CloseNotification error:", err);
reject(err);
return;
}
pendingNotifications.delete(notificationId);
resolve();
},
);
});
} catch (error) {
console.error("[LinuxNotification] Failed to close notification:", error);
}
} }
export async function getCapabilities(): Promise<string[]> { export async function getCapabilities(): Promise<string[]> {
try { if (process.platform !== "linux") {
const bus = await getSessionBus();
return new Promise((resolve, reject) => {
bus.invoke(
{
destination: BUS_NAME,
path: OBJECT_PATH,
interface: "org.freedesktop.Notifications",
member: "GetCapabilities",
},
(err: Error | null, result: any) => {
if (err) {
console.error("[LinuxNotification] GetCapabilities error:", err);
reject(err);
return;
}
resolve(result as string[]);
},
);
});
} catch (error) {
console.error("[LinuxNotification] Failed to get capabilities:", error);
return []; return [];
} }
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
} }
export function onNotificationAction(callback: NotificationCallback): void { export function onNotificationAction(callback: NotificationCallback): void {
@@ -262,83 +234,17 @@ export function removeNotificationCallback(
} }
} }
function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) {
try {
callback(sessionId);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
}
}
export async function initLinuxNotificationService(): Promise<void> { export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") { if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init"); console.log("[LinuxNotification] Not on Linux, skipping init");
return; return;
} }
try { if (!Notification.isSupported()) {
const bus = await getSessionBus(); console.warn("[LinuxNotification] Notification API is not supported");
return;
// 监听底层connection的message事件
bus.connection.on("message", (msg: any) => {
// type 4表示SIGNAL
if (
msg.type === 4 &&
msg.path === OBJECT_PATH &&
msg.interface === "org.freedesktop.Notifications"
) {
if (msg.member === "ActionInvoked") {
const [notificationId, actionId] = msg.body;
console.log(
`[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`,
);
// 如果用户点击了通知本体actionId会是'default'
if (actionId === "default") {
const data = pendingNotifications.get(notificationId);
if (data?.sessionId) {
triggerNotificationCallback(data.sessionId);
}
}
}
if (msg.member === "NotificationClosed") {
const [notificationId] = msg.body;
pendingNotifications.delete(notificationId);
}
}
});
// AddMatch用来接收信号
await new Promise<void>((resolve, reject) => {
bus.invoke(
{
destination: "org.freedesktop.DBus",
path: "/org/freedesktop/DBus",
interface: "org.freedesktop.DBus",
member: "AddMatch",
signature: "s",
body: ["type='signal',interface='org.freedesktop.Notifications'"],
},
(err: Error | null) => {
if (err) {
console.error("[LinuxNotification] AddMatch error:", err);
reject(err);
return;
}
resolve();
},
);
});
console.log("[LinuxNotification] Service initialized");
// 打印相关日志
const caps = await getCapabilities();
console.log("[LinuxNotification] Server capabilities:", caps);
} catch (error) {
console.error("[LinuxNotification] Failed to initialize:", error);
} }
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
} }

View File

@@ -121,6 +121,9 @@ export class WcdbCore {
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map() private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly hardlinkCacheMaxEntries = 20000 private readonly hardlinkCacheMaxEntries = 20000
private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null
private mediaStreamSessionCacheAt = 0
private readonly mediaStreamSessionCacheTtlMs = 12 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null private lastResolvedLogPath: string | null = null
@@ -277,7 +280,9 @@ export class WcdbCore {
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64' const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32')
const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64')
const envDllPath = process.env.WCDB_DLL_PATH const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) { if (envDllPath && envDllPath.length > 0) {
@@ -287,20 +292,33 @@ export class WcdbCore {
// 基础路径探测 // 基础路径探测
const isPackaged = typeof process['resourcesPath'] !== 'undefined' const isPackaged = typeof process['resourcesPath'] !== 'undefined'
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
const roots = [
const candidates = [ process.env.WCDB_RESOURCES_PATH || null,
// 环境变量指定 resource 目录 this.resourcesPath || null,
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, join(resourcesPath, 'resources'),
// 显式 setPaths 设置的路径 resourcesPath,
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, join(process.cwd(), 'resources')
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
join(resourcesPath, 'resources', subDir, libName),
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
join(resourcesPath, subDir, libName),
// CWD fallback
join(process.cwd(), 'resources', subDir, libName)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64'
const relativeCandidates = [
join('wcdb', platformDir, archDir, libName),
join('wcdb', platformDir, normalizedArch, libName),
join('wcdb', platformDir, 'x64', libName),
join('wcdb', platformDir, 'universal', libName),
join('wcdb', platformDir, libName)
]
const candidates: string[] = []
for (const root of roots) {
for (const relativePath of relativeCandidates) {
candidates.push(join(root, relativePath))
}
// 兼容旧目录resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
candidates.push(join(root, legacySubDir, libName))
candidates.push(join(root, libName))
}
for (const path of candidates) { for (const path of candidates) {
if (existsSync(path)) return path if (existsSync(path)) return path
} }
@@ -1465,6 +1483,11 @@ export class WcdbCore {
this.videoHardlinkCache.clear() this.videoHardlinkCache.clear()
} }
private clearMediaStreamSessionCache(): void {
this.mediaStreamSessionCache = null
this.mediaStreamSessionCacheAt = 0
}
isReady(): boolean { isReady(): boolean {
return this.ensureReady() return this.ensureReady()
} }
@@ -1580,6 +1603,7 @@ export class WcdbCore {
this.currentDbStoragePath = null this.currentDbStoragePath = null
this.initialized = false this.initialized = false
this.clearHardlinkCaches() this.clearHardlinkCaches()
this.clearMediaStreamSessionCache()
this.stopLogPolling() this.stopLogPolling()
} }
} }
@@ -1957,7 +1981,7 @@ export class WcdbCore {
error?: string error?: string
}> { }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' } if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' }
try { try {
const toInt = (value: unknown): number => { const toInt = (value: unknown): number => {
const n = Number(value || 0) const n = Number(value || 0)
@@ -2168,37 +2192,64 @@ export class WcdbCore {
const offset = Math.max(0, toInt(options?.offset)) const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
const sessionsRes = await this.getSessions() const getSessionRows = async (): Promise<{
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { success: boolean
return { success: false, error: sessionsRes.error || '读取会话失败' } rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }>
error?: string
}> => {
const now = Date.now()
const cachedRows = this.mediaStreamSessionCache
if (
cachedRows &&
now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs
) {
return { success: true, rows: cachedRows }
}
const sessionsRes = await this.getSessions()
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
return { success: false, error: sessionsRes.error || '读取会话失败' }
}
const rows = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
this.mediaStreamSessionCache = rows
this.mediaStreamSessionCacheAt = now
return { success: true, rows }
} }
const sessions = (sessionsRes.sessions || []) let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
.map((row: any) => ({ if (requestedSessionId) {
sessionId: String( sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
row.username || } else {
row.user_name || const sessionsRowsRes = await getSessionRows()
row.userName || if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
row.usrName || return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
row.UsrName || }
row.talker || sessionRows = sessionsRowsRes.rows
'' }
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
return { success: true, items: [], hasMore: false, nextOffset: offset } return { success: true, items: [], hasMore: false, nextOffset: offset }
} }
@@ -2219,10 +2270,10 @@ export class WcdbCore {
outHasMore outHasMore
) )
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `扫描媒体流失败: ${result}` } return { success: false, error: `扫描资源失败: ${result}` }
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体流失败' } if (!jsonStr) return { success: false, error: '解析资源失败' }
const rows = JSON.parse(jsonStr) const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : [] const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
@@ -2254,19 +2305,39 @@ export class WcdbCore {
rawMessageContent && rawMessageContent &&
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
) )
const content = useRawMessageContent const decodeContentIfNeeded = (): string => {
? rawMessageContent if (useRawMessageContent) return rawMessageContent
: decodeMessageContent(rawMessageContent, rawCompressContent) if (!rawMessageContent && !rawCompressContent) return ''
return decodeMessageContent(rawMessageContent, rawCompressContent)
}
const packedPayload = extractPackedPayload(row) const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const imageMd5 = localType === 3
? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined)
: undefined
const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const videoMd5 = localType === 43
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined) let content = ''
: undefined let imageMd5: string | undefined
let imageDatName: string | undefined
let videoMd5: string | undefined
if (localType === 3) {
imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined
imageDatName = extractImageDatName(row, '') || undefined
if (!imageMd5 || !imageDatName) {
content = decodeContentIfNeeded()
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
}
} else if (localType === 43) {
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
if (!videoMd5) {
content = decodeContentIfNeeded()
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
} else if (useRawMessageContent) {
// 占位态标题只依赖简单 XML已带 md5 时不做额外解压
content = rawMessageContent
}
}
return { return {
sessionId, sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
@@ -2280,7 +2351,7 @@ export class WcdbCore {
imageMd5, imageMd5,
imageDatName, imageDatName,
videoMd5, videoMd5,
content: content || undefined content: localType === 43 ? (content || undefined) : undefined
} }
}) })

View File

@@ -1,18 +0,0 @@
declare module 'dbus-native' {
namespace dbus {
interface DBusConnection {
invoke(options: any, callback: (err: Error | null, result?: any) => void): void;
on(event: string, listener: Function): void;
// 底层connection用于监听signal
connection: {
on(event: string, listener: Function): void;
};
}
// 声明sessionBus方法
function sessionBus(): DBusConnection;
function systemBus(): DBusConnection;
}
export = dbus;
}

223
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2", "@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -21,7 +20,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.6",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -3084,25 +3083,6 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/abstract-socket": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"dependencies": {
"bindings": "^1.2.1",
"nan": "^2.12.1"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3615,16 +3595,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -4459,27 +4429,6 @@
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dbus-native": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.4.0.tgz",
"integrity": "sha512-i3zvY3tdPEOaMgmK4riwupjDYRJ53rcE1Kj8rAgnLOFmBd0DekUih59qv8v+Oyils/U9p+s4sSsaBzHWLztI+Q==",
"license": "MIT",
"dependencies": {
"event-stream": "^4.0.0",
"hexy": "^0.2.10",
"long": "^4.0.0",
"optimist": "^0.6.1",
"put": "0.0.6",
"safe-buffer": "^5.1.1",
"xml2js": "^0.4.17"
},
"bin": {
"dbus2js": "bin/dbus2js.js"
},
"optionalDependencies": {
"abstract-socket": "^2.0.0"
}
},
"node_modules/debounce-fn": { "node_modules/debounce-fn": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -4848,12 +4797,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
"license": "MIT"
},
"node_modules/duplexer2": { "node_modules/duplexer2": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -5379,21 +5322,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
"integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==",
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.1",
"from": "^0.1.7",
"map-stream": "0.0.7",
"pause-stream": "^0.0.11",
"split": "^1.0.1",
"stream-combiner": "^0.2.2",
"through": "^2.3.8"
}
},
"node_modules/exceljs": { "node_modules/exceljs": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -5570,13 +5498,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT",
"optional": true
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -5664,12 +5585,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==",
"license": "MIT"
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6069,15 +5984,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hexy": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==",
"license": "MIT",
"bin": {
"hexy": "bin/hexy_cmd.js"
}
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -6631,9 +6537,9 @@
} }
}, },
"node_modules/koffi": { "node_modules/koffi": {
"version": "2.15.2", "version": "2.15.6",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.2.tgz", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.6.tgz",
"integrity": "sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==", "integrity": "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -6806,12 +6712,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -6874,12 +6774,6 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/map-stream": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==",
"license": "MIT"
},
"node_modules/markdown-table": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -8023,13 +7917,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -8222,22 +8109,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/optimist": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
"integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==",
"license": "MIT/X11",
"dependencies": {
"minimist": "~0.0.1",
"wordwrap": "~0.0.2"
}
},
"node_modules/optimist/node_modules/minimist": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
"integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==",
"license": "MIT"
},
"node_modules/ora": { "node_modules/ora": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@@ -8387,18 +8258,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/pause-stream": {
"version": "0.0.11",
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
"license": [
"MIT",
"Apache2"
],
"dependencies": {
"through": "~2.3"
}
},
"node_modules/pe-library": { "node_modules/pe-library": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -8597,15 +8456,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/put": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/put/-/put-0.0.6.tgz",
"integrity": "sha512-w0szIZ2NkqznMFqxYPRETCIi+q/S8UKis9F4yOl6/N9NDCZmbjZZT85aI4FgJf3vIPrzMPX60+odCLOaYxNWWw==",
"license": "MIT/X11",
"engines": {
"node": ">=0.3.0"
}
},
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -9467,18 +9317,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/split": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
"integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==",
"license": "MIT",
"dependencies": {
"through": "2"
},
"engines": {
"node": "*"
}
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -9510,16 +9348,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/stream-combiner": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz",
"integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==",
"license": "MIT",
"dependencies": {
"duplexer": "~0.1.1",
"through": "~2.3.4"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9788,12 +9616,6 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -10318,9 +10140,9 @@
} }
}, },
"node_modules/vite-plugin-electron": { "node_modules/vite-plugin-electron": {
"version": "0.29.1", "version": "0.28.8",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==", "integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -10380,15 +10202,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/wordwrap": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
"integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -10432,28 +10245,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -24,7 +24,6 @@
}, },
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2", "@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -35,7 +34,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0", "jieba-wasm": "^2.2.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.15.6",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
@@ -99,7 +98,7 @@
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist", "entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"win": { "win": {
"target": [ "target": [
@@ -108,19 +107,19 @@
"icon": "public/icon.ico", "icon": "public/icon.ico",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/msvcp140.dll", "from": "resources/runtime/win32/msvcp140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/msvcp140_1.dll", "from": "resources/runtime/win32/msvcp140_1.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140.dll", "from": "resources/runtime/win32/vcruntime140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140_1.dll", "from": "resources/runtime/win32/vcruntime140_1.dll",
"to": "." "to": "."
} }
] ]
@@ -136,7 +135,7 @@
"synopsis": "WeFlow for Linux", "synopsis": "WeFlow for Linux",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/linux/install.sh", "from": "resources/installer/linux/install.sh",
"to": "install.sh" "to": "install.sh"
} }
] ]
@@ -191,7 +190,7 @@
"node_modules/sherpa-onnx-*/**/*", "node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*"
], ],
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"overrides": { "overrides": {
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

Binary file not shown.

View File

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

@@ -1965,6 +1965,10 @@
color: var(--on-primary); color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
} }
.bubble-body {
align-items: flex-end;
}
} }
// 对方发送的消息 - 左侧白色 // 对方发送的消息 - 左侧白色
@@ -1974,6 +1978,10 @@
color: var(--text-primary); color: var(--text-primary);
border-radius: 18px 18px 18px 4px; border-radius: 18px 18px 18px 4px;
} }
.bubble-body {
align-items: flex-start;
}
} }
&.system { &.system {
@@ -2038,6 +2046,12 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
.message-bubble:not(.system) .bubble-content {
width: fit-content;
max-width: 100%;
}
// 表情包消息 // 表情包消息
.message-bubble.emoji { .message-bubble.emoji {
.bubble-content { .bubble-content {

View File

@@ -1,6 +1,7 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso' import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss' import './ResourcesPage.scss'
type MediaTab = 'image' | 'video' type MediaTab = 'image' | 'video'
@@ -35,10 +36,14 @@ type DialogState = {
onConfirm?: (() => void) | null onConfirm?: (() => void) | null
} }
const PAGE_SIZE = 120 const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18 const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36 const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4 const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
const INITIAL_IMAGE_PRELOAD_END = 48
const INITIAL_IMAGE_RESOLVE_END = 12
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) { const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props const { className = '', ...rest } = props
@@ -409,7 +414,13 @@ function ResourcesPage() {
} }
try { try {
await window.electronAPI.chat.connect() if (reset) {
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
setError(connectResult.error || '连接数据库失败')
return
}
}
const requestOffset = reset ? 0 : nextOffset const requestOffset = reset ? 0 : nextOffset
const streamResult = await window.electronAPI.chat.getMediaStream({ const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact, sessionId: selectedContact === 'all' ? undefined : selectedContact,
@@ -524,7 +535,6 @@ function ResourcesPage() {
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
try { try {
await window.electronAPI.chat.connect()
const sessionResult = await window.electronAPI.chat.getSessions() const sessionResult = await window.electronAPI.chat.getSessions()
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) { if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
const initialNameMap: Record<string, string> = {} const initialNameMap: Record<string, string> = {}
@@ -674,7 +684,10 @@ function ResourcesPage() {
resolvingImageCacheBatchRef.current = true resolvingImageCacheBatchRef.current = true
void (async () => { void (async () => {
try { try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true }) const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: false
})
const rows = Array.isArray(result?.rows) ? result.rows : [] const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {} const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
@@ -741,7 +754,10 @@ function ResourcesPage() {
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
} }
if (payloads.length === 0) return if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, { allowDecrypt: false }) void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
})
}, [displayItems]) }, [displayItems])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => { const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
@@ -813,14 +829,18 @@ function ResourcesPage() {
if (!pending) return if (!pending) return
pendingRangeRef.current = null pendingRangeRef.current = null
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(pending.start - 8, pending.end + 32) preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 2, pending.end + 8) resolveImageCacheRange(pending.start - 1, pending.end + 6)
return return
} }
resolvePosterRange(pending.start, pending.end) resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) }, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => { const scheduleRangeResolve = useCallback((start: number, end: number) => {
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
return
}
pendingRangeRef.current = { start, end } pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) { if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current) window.clearTimeout(rangeTimerRef.current)
@@ -832,8 +852,8 @@ function ResourcesPage() {
useEffect(() => { useEffect(() => {
if (displayItems.length === 0) return if (displayItems.length === 0) return
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80)) preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20)) resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
return return
} }
resolvePosterRange(0, Math.min(displayItems.length - 1, 12)) resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
setBatchBusy(true) setBatchBusy(true)
let success = 0 let success = 0
let failed = 0
const previewPatch: Record<string, string> = {} const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
sourcePage: 'other',
title: '资源页图片批量解密',
detail: `正在解密图片0/${imageItems.length}`,
progressText: `0 / ${imageItems.length}`,
cancelable: false
})
try { try {
let completed = 0
const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS))
let lastProgressBucket = 0
let lastProgressUpdateAt = Date.now()
const updateTaskProgress = (force: boolean = false) => {
const now = Date.now()
const bucket = Math.floor(completed / progressStep)
const crossedBucket = bucket !== lastProgressBucket
const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS
if (!force && !crossedBucket && !intervalReached) return
updateBackgroundTask(taskId, {
detail: `正在解密图片(${completed}/${imageItems.length}`,
progressText: `${completed} / ${imageItems.length}`
})
lastProgressBucket = bucket
lastProgressUpdateAt = now
}
for (const item of imageItems) { for (const item of imageItems) {
if (!item.imageMd5 && !item.imageDatName) continue if (!item.imageMd5 && !item.imageDatName) {
failed += 1
completed += 1
updateTaskProgress()
continue
}
const result = await window.electronAPI.image.decrypt({ const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId, sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined, imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined, imageDatName: item.imageDatName || undefined,
force: true force: true
}) })
if (!result?.success) continue if (!result?.success) {
success += 1 failed += 1
if (result.localPath) { } else {
const key = getItemKey(item) success += 1
previewPatch[key] = result.localPath if (result.localPath) {
updatePatch[key] = isLikelyThumbnailPreview(result.localPath) const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
} }
completed += 1
updateTaskProgress()
} }
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) { if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch })) setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
if (Object.keys(updatePatch).length > 0) { if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
} }
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`) setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成') showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
progressText: `成功 ${success} / 失败 ${failed}`
})
} catch (e) {
finishBackgroundTask(taskId, 'failed', {
detail: `资源页图片批量解密失败:${String(e)}`
})
showAlert(`批量解密失败:${String(e)}`, '批量解密失败')
} finally { } finally {
setBatchBusy(false) setBatchBusy(false)
} }

View File

@@ -10,12 +10,13 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic, Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X, UserRound ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X, UserRound,
Sparkles, Loader2, CheckCircle2, XCircle
} from 'lucide-react' } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' | 'insight'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
@@ -26,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe }, { id: 'api', label: 'API 服务', icon: Globe },
{ id: 'analytics', label: '分析', icon: BarChart2 }, { id: 'analytics', label: '分析', icon: BarChart2 },
{ id: 'insight', label: 'AI 见解', icon: Sparkles },
{ id: 'security', label: '安全', icon: ShieldCheck }, { id: 'security', label: '安全', icon: ShieldCheck },
{ id: 'updates', label: '版本更新', icon: RefreshCw }, { id: 'updates', label: '版本更新', icon: RefreshCw },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
@@ -123,7 +125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setHttpApiToken(token) setHttpApiToken(token)
await configService.setHttpApiToken(token) await configService.setHttpApiToken(token)
showMessage('已生成保存新的 Access Token', true) showMessage('已生成<EFBFBD><EFBFBD>保存新的 Access Token', true)
} }
const clearApiToken = async () => { const clearApiToken = async () => {
@@ -213,6 +215,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
// AI 见解 state
const [aiInsightEnabled, setAiInsightEnabled] = useState(false)
const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('')
const [aiInsightApiKey, setAiInsightApiKey] = useState('')
const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini')
const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3)
const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false)
const [isTestingInsight, setIsTestingInsight] = useState(false)
const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null)
const [showInsightApiKey, setShowInsightApiKey] = useState(false)
const [isTriggeringInsightTest, setIsTriggeringInsightTest] = useState(false)
const [insightTriggerResult, setInsightTriggerResult] = useState<{ success: boolean; message: string } | null>(null)
const [aiInsightWhitelistEnabled, setAiInsightWhitelistEnabled] = useState(false)
const [aiInsightWhitelist, setAiInsightWhitelist] = useState<Set<string>>(new Set())
const [insightWhitelistSearch, setInsightWhitelistSearch] = useState('')
const [aiInsightCooldownMinutes, setAiInsightCooldownMinutes] = useState(120)
const [aiInsightScanIntervalHours, setAiInsightScanIntervalHours] = useState(4)
const [aiInsightContextCount, setAiInsightContextCount] = useState(40)
const [aiInsightSystemPrompt, setAiInsightSystemPrompt] = useState('')
const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false)
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
const [isWayland, setIsWayland] = useState(false) const [isWayland, setIsWayland] = useState(false)
useEffect(() => { useEffect(() => {
const checkWaylandStatus = async () => { const checkWaylandStatus = async () => {
@@ -438,6 +463,37 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
// 加载 AI 见解配置
const savedAiInsightEnabled = await configService.getAiInsightEnabled()
const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl()
const savedAiInsightApiKey = await configService.getAiInsightApiKey()
const savedAiInsightApiModel = await configService.getAiInsightApiModel()
const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays()
const savedAiInsightAllowContext = await configService.getAiInsightAllowContext()
const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled()
const savedAiInsightWhitelist = await configService.getAiInsightWhitelist()
const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes()
const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours()
const savedAiInsightContextCount = await configService.getAiInsightContextCount()
const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt()
const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled()
const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken()
const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds()
setAiInsightEnabled(savedAiInsightEnabled)
setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl)
setAiInsightApiKey(savedAiInsightApiKey)
setAiInsightApiModel(savedAiInsightApiModel)
setAiInsightSilenceDays(savedAiInsightSilenceDays)
setAiInsightAllowContext(savedAiInsightAllowContext)
setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled)
setAiInsightWhitelist(new Set(savedAiInsightWhitelist))
setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes)
setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours)
setAiInsightContextCount(savedAiInsightContextCount)
setAiInsightSystemPrompt(savedAiInsightSystemPrompt)
setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled)
setAiInsightTelegramToken(savedAiInsightTelegramToken)
setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds)
} catch (e: any) { } catch (e: any) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
@@ -579,7 +635,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
await handleCheckUpdate() await handleCheckUpdate()
} catch (e: any) { } catch (e: any) {
showMessage(`切换更新渠道败: ${e}`, false) showMessage(`切换更新渠道<EFBFBD><EFBFBD>败: ${e}`, false)
} }
} }
@@ -820,16 +876,19 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
useEffect(() => { useEffect(() => {
if (activeTab !== 'antiRevoke') return if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
let canceled = false let canceled = false
;(async () => { ;(async () => {
try { try {
// 两个 Tab 都需要会话列表antiRevoke 还需要额外检查防撤回状态
const sessionIds = await ensureAntiRevokeSessionsLoaded() const sessionIds = await ensureAntiRevokeSessionsLoaded()
if (canceled) return if (canceled) return
await handleRefreshAntiRevokeStatus(sessionIds) if (activeTab === 'antiRevoke') {
await handleRefreshAntiRevokeStatus(sessionIds)
}
} catch (e: any) { } catch (e: any) {
if (!canceled) { if (!canceled) {
showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false) showMessage(`加载会话失败: ${e?.message || String(e)}`, false)
} }
} }
})() })()
@@ -1171,7 +1230,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (result.success && result.aesKey) { if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey) setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片钥') setImageKeyStatus('已获取图片<EFBFBD><EFBFBD>钥')
showMessage('已自动获取图片密钥', true) showMessage('已自动获取图片密钥', true)
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
const newAesKey = result.aesKey const newAesKey = result.aesKey
@@ -1613,7 +1672,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span>
<div className="log-toggle-line"> <div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span> <span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle"> <label className="switch" htmlFor="notification-enabled-toggle">
@@ -2451,6 +2510,627 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
} }
const handleTestInsightConnection = async () => {
setIsTestingInsight(true)
setInsightTestResult(null)
try {
const result = await (window.electronAPI as any).insight.testConnection()
setInsightTestResult(result)
} catch (e: any) {
setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
} finally {
setIsTestingInsight(false)
}
}
const renderInsightTab = () => (
<div className="tab-content">
{/* 总开关 */}
<div className="form-group">
<label>AI </label>
<span className="form-hint">
AI
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightEnabled(val)
await configService.setAiInsightEnabled(val)
showMessage(val ? 'AI 见解已开启' : 'AI 见解已关闭', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="divider" />
{/* API 配置 */}
<div className="form-group">
<label>API </label>
<span className="form-hint">
OpenAI <strong>Base URL</strong><strong></strong>
<code>/chat/completions</code>
<br />
<code>https://api.ohmygpt.com/v1</code> 或 <code>https://api.openai.com/v1</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiBaseUrl}
placeholder="https://api.ohmygpt.com/v1"
onChange={(e) => {
const val = e.target.value
setAiInsightApiBaseUrl(val)
scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val))
}}
style={{ fontFamily: 'monospace' }}
/>
</div>
<div className="form-group">
<label>API Key</label>
<span className="form-hint">
API Key
</span>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<input
type={showInsightApiKey ? 'text' : 'password'}
className="field-input"
value={aiInsightApiKey}
placeholder="sk-..."
onChange={(e) => {
const val = e.target.value
setAiInsightApiKey(val)
scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val))
}}
style={{ flex: 1, fontFamily: 'monospace' }}
/>
<button
className="btn btn-secondary"
onClick={() => setShowInsightApiKey(!showInsightApiKey)}
title={showInsightApiKey ? '隐藏' : '显示'}
>
{showInsightApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
{aiInsightApiKey && (
<button
className="btn btn-danger"
onClick={async () => {
setAiInsightApiKey('')
await configService.setAiInsightApiKey('')
}}
title="清除 Key"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
API 使
<br />
<code>gpt-4o-mini</code><code>gpt-4o</code><code>deepseek-chat</code><code>claude-3-5-haiku-20241022</code>
</span>
<input
type="text"
className="field-input"
value={aiInsightApiModel}
placeholder="gpt-4o-mini"
onChange={(e) => {
const val = e.target.value.trim() || 'gpt-4o-mini'
setAiInsightApiModel(val)
scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val))
}}
style={{ width: 260, fontFamily: 'monospace' }}
/>
</div>
{/* 测试连接 + 触发测试 */}
<div className="form-group">
<label></label>
<span className="form-hint">
"测试 API 连接" Key URL "立即触发测试见解"API
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
{/* 测试 API 连接 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={handleTestInsightConnection}
disabled={isTestingInsight || !aiInsightApiBaseUrl || !aiInsightApiKey}
>
{isTestingInsight ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<> API </>
)}
</button>
{insightTestResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTestResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTestResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTestResult.message}
</span>
)}
</div>
{/* 触发测试见解 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary"
onClick={async () => {
setIsTriggeringInsightTest(true)
setInsightTriggerResult(null)
try {
const result = await (window.electronAPI as any).insight.triggerTest()
setInsightTriggerResult(result)
} catch (e: any) {
setInsightTriggerResult({ success: false, message: `调用失败:${e?.message || String(e)}` })
} finally {
setIsTriggeringInsightTest(false)
}
}}
disabled={isTriggeringInsightTest || !aiInsightEnabled || !aiInsightApiBaseUrl || !aiInsightApiKey}
title={!aiInsightEnabled ? '请先开启 AI 见解总开关' : ''}
>
{isTriggeringInsightTest ? (
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} />...</>
) : (
<></>
)}
</button>
{insightTriggerResult && (
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTriggerResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
{insightTriggerResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{insightTriggerResult.message}
</span>
)}
</div>
</div>
</div>
<div className="divider" />
{/* 行为配置 */}
<div className="form-group">
<label></label>
<span className="form-hint">
<strong>0</strong> AI
</span>
<input
type="number"
className="field-input"
value={aiInsightCooldownMinutes}
min={0}
max={10080}
onChange={(e) => {
const val = Math.max(0, parseInt(e.target.value, 10) || 0)
setAiInsightCooldownMinutes(val)
scheduleConfigSave('aiInsightCooldownMinutes', () => configService.setAiInsightCooldownMinutes(val))
}}
style={{ width: 120 }}
/>
{aiInsightCooldownMinutes === 0 && (
<span style={{ marginLeft: 10, fontSize: 12, color: 'var(--color-warning, #f59e0b)' }}>
DB
</span>
)}
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
0.1 6
</span>
<input
type="number"
className="field-input"
value={aiInsightScanIntervalHours}
min={0.1}
max={168}
step={0.5}
onChange={(e) => {
const val = Math.max(0.1, parseFloat(e.target.value) || 4)
setAiInsightScanIntervalHours(val)
scheduleConfigSave('aiInsightScanIntervalHours', () => configService.setAiInsightScanIntervalHours(val))
}}
style={{ width: 120 }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
</span>
<input
type="number"
className="field-input"
value={aiInsightSilenceDays}
min={1}
max={365}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value, 10) || 3)
setAiInsightSilenceDays(val)
scheduleConfigSave('aiInsightSilenceDays', () => configService.setAiInsightSilenceDays(val))
}}
style={{ width: 100 }}
/>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">
N AI
<br />
<strong></strong>AI
<br />
<strong></strong> API
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightAllowContext ? '已授权' : '未授权'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightAllowContext}
onChange={async (e) => {
const val = e.target.checked
setAiInsightAllowContext(val)
await configService.setAiInsightAllowContext(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
{aiInsightAllowContext && (
<div className="form-group">
<label></label>
<span className="form-hint">
AI token
</span>
<input
type="number"
className="field-input"
value={aiInsightContextCount}
min={1}
max={200}
onChange={(e) => {
const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40))
setAiInsightContextCount(val)
scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val))
}}
style={{ width: 100 }}
/>
</div>
)}
<div className="divider" />
{/* 自定义 System Prompt */}
{(() => {
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
要求:
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
3. 输出纯文本,不使用 Markdown。
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
// 展示值:有自定义内容时显示自定义内容,否则显示默认值(可直接编辑)
const displayValue = aiInsightSystemPrompt || DEFAULT_SYSTEM_PROMPT
return (
<div className="form-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<label style={{ marginBottom: 0 }}> AI </label>
<button
className="button-secondary"
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={async () => {
// 恢复默认清空自定义值UI 回到显示默认内容的状态
setAiInsightSystemPrompt('')
await configService.setAiInsightSystemPrompt('')
}}
>
</button>
</div>
<span className="form-hint">
</span>
<textarea
className="field-input"
rows={8}
style={{ width: '100%', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
value={displayValue}
onChange={(e) => {
const val = e.target.value
// 如果用户把内容改得和默认值一样,仍存自定义值(不影响功能)
setAiInsightSystemPrompt(val)
scheduleConfigSave('aiInsightSystemPrompt', () => configService.setAiInsightSystemPrompt(val))
}}
/>
</div>
)
})()}
<div className="divider" />
{/* Telegram 推送 */}
<div className="form-group">
<label>Telegram Bot </label>
<span className="form-hint">
Telegram /便 Bot Token @BotFatherChat ID @userinfobot ID
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightTelegramEnabled ? '已启用' : '未启用'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightTelegramEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightTelegramEnabled(val)
await configService.setAiInsightTelegramEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
{aiInsightTelegramEnabled && (
<>
<div className="form-group">
<label>Bot Token</label>
<input
type="password"
className="field-input"
style={{ width: '100%' }}
placeholder="110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"
value={aiInsightTelegramToken}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramToken(val)
scheduleConfigSave('aiInsightTelegramToken', () => configService.setAiInsightTelegramToken(val))
}}
/>
</div>
<div className="form-group">
<label>Chat ID</label>
<input
type="text"
className="field-input"
style={{ width: '100%' }}
placeholder="123456789, -987654321"
value={aiInsightTelegramChatIds}
onChange={(e) => {
const val = e.target.value
setAiInsightTelegramChatIds(val)
scheduleConfigSave('aiInsightTelegramChatIds', () => configService.setAiInsightTelegramChatIds(val))
}}
/>
</div>
</>
)}
<div className="divider" />
{/* 对话白名单 */}
{(() => {
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
const keyword = insightWhitelistSearch.trim().toLowerCase()
const filteredSessions = sortedSessions.filter((s) => {
const id = s.username?.trim() || ''
if (!id || id.endsWith('@chatroom') || id.toLowerCase().includes('placeholder')) return false
if (!keyword) return true
return (
String(s.displayName || '').toLowerCase().includes(keyword) ||
id.toLowerCase().includes(keyword)
)
})
const filteredIds = filteredSessions.map((s) => s.username)
const selectedCount = aiInsightWhitelist.size
const selectedInFilteredCount = filteredIds.filter((id) => aiInsightWhitelist.has(id)).length
const allFilteredSelected = filteredIds.length > 0 && selectedInFilteredCount === filteredIds.length
const toggleSession = (id: string) => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const saveWhitelist = async (next: Set<string>) => {
await configService.setAiInsightWhitelist(Array.from(next))
}
const selectAllFiltered = () => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
for (const id of filteredIds) next.add(id)
void saveWhitelist(next)
return next
})
}
const clearSelection = () => {
const next = new Set<string>()
setAiInsightWhitelist(next)
void saveWhitelist(next)
}
return (
<div className="anti-revoke-tab">
<div className="anti-revoke-hero">
<div className="anti-revoke-hero-main">
<h3></h3>
<p>
AI
</p>
</div>
<div className="anti-revoke-metrics">
<div className="anti-revoke-metric is-total">
<span className="label"></span>
<span className="value">{filteredIds.length + (keyword ? 0 : 0)}</span>
</div>
<div className="anti-revoke-metric is-installed">
<span className="label"></span>
<span className="value">{selectedCount}</span>
</div>
</div>
</div>
<div className="log-toggle-line" style={{ marginBottom: 12 }}>
<span className="log-status" style={{ fontWeight: 600 }}>
{aiInsightWhitelistEnabled ? '白名单已启用(仅对勾选对话生效)' : '白名单未启用(对所有私聊生效)'}
</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightWhitelistEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightWhitelistEnabled(val)
await configService.setAiInsightWhitelistEnabled(val)
}}
/>
<span className="switch-slider" />
</label>
</div>
<div className="anti-revoke-control-card">
<div className="anti-revoke-toolbar">
<div className="filter-search-box anti-revoke-search">
<Search size={14} />
<input
type="text"
placeholder="搜索私聊对话..."
value={insightWhitelistSearch}
onChange={(e) => setInsightWhitelistSearch(e.target.value)}
/>
</div>
<div className="anti-revoke-toolbar-actions">
<div className="anti-revoke-btn-group">
<button
className="btn btn-secondary btn-sm"
onClick={selectAllFiltered}
disabled={filteredIds.length === 0 || allFilteredSelected}
>
</button>
<button
className="btn btn-secondary btn-sm"
onClick={clearSelection}
disabled={selectedCount === 0}
>
</button>
</div>
</div>
</div>
<div className="anti-revoke-batch-actions">
<div className="anti-revoke-selected-count">
<span> <strong>{selectedCount}</strong> </span>
<span> <strong>{selectedInFilteredCount}</strong> / {filteredIds.length}</span>
</div>
</div>
</div>
<div className="anti-revoke-list">
{filteredSessions.length === 0 ? (
<div className="anti-revoke-empty">
{insightWhitelistSearch ? '没有匹配的对话' : '暂无私聊对话'}
</div>
) : (
<>
<div className="anti-revoke-list-header">
<span>{filteredSessions.length}</span>
<span></span>
</div>
{filteredSessions.map((session) => {
const isSelected = aiInsightWhitelist.has(session.username)
return (
<div
key={session.username}
className={`anti-revoke-row ${isSelected ? 'selected' : ''}`}
>
<label className="anti-revoke-row-main">
<span className="anti-revoke-check">
<input
type="checkbox"
checked={isSelected}
onChange={async () => {
setAiInsightWhitelist((prev) => {
const next = new Set(prev)
if (next.has(session.username)) next.delete(session.username)
else next.add(session.username)
void configService.setAiInsightWhitelist(Array.from(next))
return next
})
}}
/>
<span className="check-indicator" aria-hidden="true">
<Check size={12} />
</span>
</span>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={30}
/>
<div className="anti-revoke-row-text">
<span className="name">{session.displayName || session.username}</span>
</div>
</label>
<div className="anti-revoke-row-status">
<span className={`status-badge ${isSelected ? 'installed' : 'not-installed'}`}>
<i className="status-dot" aria-hidden="true" />
{isSelected ? '已加入' : '未加入'}
</span>
</div>
</div>
)
})}
</>
)}
</div>
</div>
)
})()}
<div className="divider" />
{/* 工作原理说明 */}
<div className="form-group">
<label></label>
<div className="api-docs">
<div className="api-item">
<p className="api-desc" style={{ lineHeight: 1.7 }}>
<strong></strong> 500ms <br />
<strong></strong> 4 <br />
<strong></strong> AI AI <br />
<strong></strong> API WeFlow
</p>
</div>
</div>
</div>
</div>
)
const renderApiTab = () => ( const renderApiTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
@@ -2552,7 +3232,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={`http://${httpApiHost}:${httpApiPort}`} value={`http://${httpApiHost}:${httpApiPort}`}
readOnly readOnly
/> />
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复"> <button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复<EFBFBD><EFBFBD><EFBFBD>">
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
@@ -2686,7 +3366,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try { try {
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello') const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!verifyResult.success) { if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello 证失败', false) showMessage(verifyResult.error || 'Windows Hello <EFBFBD><EFBFBD>证失败', false)
return return
} }
@@ -2918,7 +3598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
onClick={handleSetupHello} onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword} disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
> >
{isSettingHello ? '置中...' : '开启与设置'} {isSettingHello ? '<EFBFBD><EFBFBD><EFBFBD>置中...' : '开启与设置'}
</button> </button>
)} )}
</div> </div>
@@ -2996,7 +3676,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="updates-hero-main"> <div className="updates-hero-main">
<span className="updates-chip"></span> <span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2> <h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更'}</p> <p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p>
</div> </div>
<div className="updates-hero-action"> <div className="updates-hero-action">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (
@@ -3135,6 +3815,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{activeTab === 'models' && renderModelsTab()} {activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()} {activeTab === 'api' && renderApiTab()}
{activeTab === 'insight' && renderInsightTab()}
{activeTab === 'updates' && renderUpdatesTab()} {activeTab === 'updates' && renderUpdatesTab()}
{activeTab === 'analytics' && renderAnalyticsTab()} {activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()} {activeTab === 'security' && renderSecurityTab()}

View File

@@ -79,7 +79,24 @@ export const CONFIG_KEYS = {
// 数据收集 // 数据收集
ANALYTICS_CONSENT: 'analyticsConsent', ANALYTICS_CONSENT: 'analyticsConsent',
ANALYTICS_DENY_COUNT: 'analyticsDenyCount' ANALYTICS_DENY_COUNT: 'analyticsDenyCount',
// AI 见解
AI_INSIGHT_ENABLED: 'aiInsightEnabled',
AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl',
AI_INSIGHT_API_KEY: 'aiInsightApiKey',
AI_INSIGHT_API_MODEL: 'aiInsightApiModel',
AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays',
AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext',
AI_INSIGHT_WHITELIST_ENABLED: 'aiInsightWhitelistEnabled',
AI_INSIGHT_WHITELIST: 'aiInsightWhitelist',
AI_INSIGHT_COOLDOWN_MINUTES: 'aiInsightCooldownMinutes',
AI_INSIGHT_SCAN_INTERVAL_HOURS: 'aiInsightScanIntervalHours',
AI_INSIGHT_CONTEXT_COUNT: 'aiInsightContextCount',
AI_INSIGHT_SYSTEM_PROMPT: 'aiInsightSystemPrompt',
AI_INSIGHT_TELEGRAM_ENABLED: 'aiInsightTelegramEnabled',
AI_INSIGHT_TELEGRAM_TOKEN: 'aiInsightTelegramToken',
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -488,7 +505,7 @@ export async function setExportDefaultTxtColumns(columns: string[]): Promise<voi
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
} }
// 获取导出默认并发 // 获取导出默认并发<EFBFBD><EFBFBD>
export async function getExportDefaultConcurrency(): Promise<number | null> { export async function getExportDefaultConcurrency(): Promise<number | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY) const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY)
if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'number' && Number.isFinite(value)) return value
@@ -1551,3 +1568,140 @@ export async function getHttpApiHost(): Promise<string> {
export async function setHttpApiHost(host: string): Promise<void> { export async function setHttpApiHost(host: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_HOST, host) await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
} }
// ─── AI 见解 ──────────────────────────────────────────────────────────────────
export async function getAiInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
return value === true
}
export async function setAiInsightEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ENABLED, enabled)
}
export async function getAiInsightApiBaseUrl(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightApiBaseUrl(url: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL, url)
}
export async function getAiInsightApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightApiKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_KEY, key)
}
export async function getAiInsightApiModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL)
return typeof value === 'string' && value.trim() ? value.trim() : 'gpt-4o-mini'
}
export async function setAiInsightApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_MODEL, model)
}
export async function getAiInsightSilenceDays(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_SILENCE_DAYS)
return typeof value === 'number' && value > 0 ? value : 3
}
export async function setAiInsightSilenceDays(days: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_SILENCE_DAYS, days)
}
export async function getAiInsightAllowContext(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT)
return value === true
}
export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
}
export async function getAiInsightWhitelistEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED)
return value === true
}
export async function setAiInsightWhitelistEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST_ENABLED, enabled)
}
export async function getAiInsightWhitelist(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_WHITELIST)
return Array.isArray(value) ? (value as string[]) : []
}
export async function setAiInsightWhitelist(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_WHITELIST, list)
}
export async function getAiInsightCooldownMinutes(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_COOLDOWN_MINUTES)
return typeof value === 'number' && value >= 0 ? value : 120
}
export async function setAiInsightCooldownMinutes(minutes: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_COOLDOWN_MINUTES, minutes)
}
export async function getAiInsightScanIntervalHours(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_SCAN_INTERVAL_HOURS)
return typeof value === 'number' && value > 0 ? value : 4
}
export async function setAiInsightScanIntervalHours(hours: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_SCAN_INTERVAL_HOURS, hours)
}
export async function getAiInsightContextCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_CONTEXT_COUNT)
return typeof value === 'number' && value > 0 ? value : 40
}
export async function setAiInsightContextCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_CONTEXT_COUNT, count)
}
export async function getAiInsightSystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightSystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_SYSTEM_PROMPT, prompt)
}
export async function getAiInsightTelegramEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_ENABLED)
return value === true
}
export async function setAiInsightTelegramEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_ENABLED, enabled)
}
export async function getAiInsightTelegramToken(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_TOKEN)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightTelegramToken(token: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_TOKEN, token)
}
export async function getAiInsightTelegramChatIds(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS)
return typeof value === 'string' ? value : ''
}
export async function setAiInsightTelegramChatIds(chatIds: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS, chatIds)
}

View File

@@ -1,4 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
export interface BatchImageDecryptState { export interface BatchImageDecryptState {
isBatchDecrypting: boolean isBatchDecrypting: boolean
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
result: { success: number; fail: number } result: { success: number; fail: number }
startTime: number startTime: number
sessionName: string sessionName: string
taskId: string | null
startDecrypt: (total: number, sessionName: string) => void startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
updateProgress: (current: number, total: number) => void updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void setShowToast: (show: boolean) => void
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
reset: () => void reset: () => void
} }
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({ const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
isBatchDecrypting: false, isBatchDecrypting: false,
progress: { current: 0, total: 0 }, progress: { current: 0, total: 0 },
showToast: false, showToast: false,
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
result: { success: 0, fail: 0 }, result: { success: 0, fail: 0 },
startTime: 0, startTime: 0,
sessionName: '', sessionName: '',
taskId: null,
startDecrypt: (total, sessionName) => set({ startDecrypt: (total, sessionName, sourcePage = 'chat') => {
isBatchDecrypting: true, const previousTaskId = get().taskId
progress: { current: 0, total }, if (previousTaskId) {
showToast: true, taskProgressUpdateMeta.delete(previousTaskId)
showResultToast: false, finishBackgroundTask(previousTaskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '已被新的批量解密任务替换',
startTime: Date.now(), progressText: '已替换'
sessionName })
}), }
updateProgress: (current, total) => set({ const normalizedProgress = clampProgress(0, total)
progress: { current, total } const normalizedSessionName = String(sessionName || '').trim()
}), const title = normalizedSessionName
? `图片批量解密(${normalizedSessionName}`
: '图片批量解密'
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: false
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
finishDecrypt: (success, fail) => set({ set({
isBatchDecrypting: false, isBatchDecrypting: true,
showToast: false, progress: normalizedProgress,
showResultToast: true, showToast: true,
result: { success, fail }, showResultToast: false,
startTime: 0 result: { success: 0, fail: 0 },
}), startTime: Date.now(),
sessionName: normalizedSessionName,
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
if (crossedBucket || intervalReached || isFinal) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
) {
set({
progress: normalizedProgress
})
}
},
finishDecrypt: (success, fail) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
finishBackgroundTask(taskId, status, {
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }), setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }), setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({ reset: () => {
isBatchDecrypting: false, const taskId = get().taskId
progress: { current: 0, total: 0 }, if (taskId) {
showToast: false, taskProgressUpdateMeta.delete(taskId)
showResultToast: false, finishBackgroundTask(taskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '批量解密任务已重置',
startTime: 0, progressText: '已停止'
sessionName: '' })
}) }
}))
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null
})
}
}))

View File

@@ -403,10 +403,16 @@ export interface ElectronAPI {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -414,7 +420,7 @@ export interface ElectronAPI {
}> }>
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean> ) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void