mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 15:08:44 +00:00
Compare commits
56 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312a9b76b4 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
dcad30bc39 | ||
|
|
73ee524d1f | ||
|
|
4af8334f50 | ||
|
|
43fed79204 | ||
|
|
b356814ebb | ||
|
|
0acad9927a | ||
|
|
5bc46fadfc | ||
|
|
489b545965 | ||
|
|
36533d07f8 | ||
|
|
625e4f8e6a | ||
|
|
c4774e1ce1 | ||
|
|
e1682f99d2 | ||
|
|
a23461bfce | ||
|
|
73fc36e63a | ||
|
|
4beddb7a62 | ||
|
|
b130165831 | ||
|
|
9adffc3cd7 | ||
|
|
a52619c4d5 | ||
|
|
cf40d3ad63 | ||
|
|
14a2475fb1 | ||
|
|
76a55998c2 | ||
|
|
1ec8d54e96 | ||
|
|
b8cd9a8c38 | ||
|
|
7fa26b0716 | ||
|
|
dc49bf3877 | ||
|
|
d825dada59 | ||
|
|
81ec51be33 | ||
|
|
fbecda9f1e | ||
|
|
b6950d4027 | ||
|
|
f31327b528 | ||
|
|
c4c7df2608 | ||
|
|
7fb98d764a | ||
|
|
792621d982 | ||
|
|
c92b50b6ec | ||
|
|
f83117df20 | ||
|
|
b7b7260838 | ||
|
|
dd960d30ff | ||
|
|
89f3ec57f5 | ||
|
|
95f1e73a39 | ||
|
|
aa029fe113 | ||
|
|
5971757a28 | ||
|
|
1e16ea887b | ||
|
|
837f15c5e8 | ||
|
|
f71ff7392c | ||
|
|
97ba95e2be | ||
|
|
6aae23180f | ||
|
|
49e82e43e4 | ||
|
|
301c490893 | ||
|
|
93a9df48f4 |
26
.github/workflows/dev-daily-fixed.yml
vendored
26
.github/workflows/dev-daily-fixed.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: dev-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -329,9 +333,21 @@ jobs:
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body dev_release_notes.md \
|
||||
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
|
||||
26
.github/workflows/preview-nightly-main.yml
vendored
26
.github/workflows/preview-nightly-main.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -371,9 +375,21 @@ jobs:
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body preview_release_notes.md \
|
||||
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
|
||||
17
.github/workflows/security-scan.yml
vendored
17
.github/workflows/security-scan.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
@@ -24,15 +27,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -71,10 +74,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -56,6 +56,8 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
!resources/wcdb/
|
||||
!resources/wcdb/**
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
|
||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cloudControlService } from './services/cloudControlService'
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
import { insightService } from './services/insightService'
|
||||
import { bizService } from './services/bizService'
|
||||
|
||||
// 配置自动更新
|
||||
@@ -181,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||
autoUpdater.channel = nextUpdaterChannel
|
||||
lastAppliedUpdaterChannel = nextUpdaterChannel
|
||||
lastAppliedUpdaterFeedUrl = nextFeedUrl
|
||||
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||
}
|
||||
|
||||
applyAutoUpdateChannel('startup')
|
||||
@@ -1618,9 +1618,23 @@ function registerIpcHandlers() {
|
||||
applyAutoUpdateChannel('settings')
|
||||
}
|
||||
void messagePushService.handleConfigChanged(key)
|
||||
void insightService.handleConfigChanged(key)
|
||||
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 () => {
|
||||
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
|
||||
const result = setSystemLaunchAtStartup(false)
|
||||
@@ -1630,6 +1644,7 @@ function registerIpcHandlers() {
|
||||
}
|
||||
configService?.clear()
|
||||
messagePushService.handleConfigCleared()
|
||||
insightService.handleConfigCleared()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -2558,7 +2573,13 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
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)
|
||||
})
|
||||
ipcMain.handle(
|
||||
@@ -2566,13 +2587,14 @@ function registerIpcHandlers() {
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
}))
|
||||
return { success: true, rows }
|
||||
@@ -2583,7 +2605,7 @@ function registerIpcHandlers() {
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
return true
|
||||
@@ -3478,8 +3500,10 @@ app.whenReady().then(async () => {
|
||||
registerIpcHandlers()
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
insightService.handleDbMonitorChange(type, json)
|
||||
})
|
||||
messagePushService.start()
|
||||
insightService.start()
|
||||
await delay(200)
|
||||
|
||||
// 检查配置状态
|
||||
@@ -3600,6 +3624,7 @@ app.on('before-quit', async () => {
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
insightService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
|
||||
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -266,15 +271,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) =>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
@@ -492,5 +503,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
},
|
||||
|
||||
// AI 见解
|
||||
insight: {
|
||||
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
|
||||
}
|
||||
})
|
||||
|
||||
219
electron/services/avatarFileCacheService.ts
Normal file
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "./config";
|
||||
|
||||
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||
export class AvatarFileCacheService {
|
||||
private static instance: AvatarFileCacheService | null = null;
|
||||
|
||||
// 头像文件缓存目录
|
||||
private readonly cacheDir: string;
|
||||
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||
new Map();
|
||||
// LRU 追踪:文件路径->最后访问时间
|
||||
private readonly lruOrder: string[] = [];
|
||||
private readonly maxCacheFiles = 100;
|
||||
|
||||
private constructor() {
|
||||
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||
this.cacheDir = join(basePath, "avatar-files");
|
||||
this.ensureCacheDir();
|
||||
this.loadLruOrder();
|
||||
}
|
||||
|
||||
public static getInstance(): AvatarFileCacheService {
|
||||
if (!AvatarFileCacheService.instance) {
|
||||
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||
}
|
||||
return AvatarFileCacheService.instance;
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
// 同步确保目录存在(构造函数调用)
|
||||
try {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async ensureCacheDirAsync(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getFilePath(url: string): string {
|
||||
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||
const hash = this.hashString(url);
|
||||
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
private async loadLruOrder(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
// 按修改时间排序(旧的在前)
|
||||
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||
try {
|
||||
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||
this.lruOrder.length = 0;
|
||||
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private updateLru(fileName: string): void {
|
||||
const index = this.lruOrder.indexOf(fileName);
|
||||
if (index > -1) {
|
||||
this.lruOrder.splice(index, 1);
|
||||
}
|
||||
this.lruOrder.push(fileName);
|
||||
}
|
||||
|
||||
private async evictIfNeeded(): Promise<void> {
|
||||
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||
const oldest = this.lruOrder.shift();
|
||||
if (oldest) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, oldest));
|
||||
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAvatar(url: string): Promise<string | null> {
|
||||
const localPath = this.getFilePath(url);
|
||||
|
||||
// 检查文件是否已存在
|
||||
try {
|
||||
await fs.access(localPath);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
return localPath;
|
||||
} catch {}
|
||||
|
||||
await this.ensureCacheDirAsync();
|
||||
await this.evictIfNeeded();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const options = {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
Referer: "https://servicewechat.com/",
|
||||
Accept:
|
||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
};
|
||||
|
||||
const callback = (res: IncomingMessage) => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.writeFile(localPath, buffer);
|
||||
const fileName = localPath.split("/").pop()!;
|
||||
this.updateLru(fileName);
|
||||
console.log(
|
||||
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||
);
|
||||
resolve(localPath);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", () => resolve(null));
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取头像本地文件路径,如果需要会下载
|
||||
* 同一URL并发调用会复用同一个下载任务
|
||||
*/
|
||||
async getAvatarPath(url: string): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
// 检查是否有正在进行的下载
|
||||
const pending = this.pendingDownloads.get(url);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// 发起新下载
|
||||
const downloadPromise = this.downloadAvatar(url);
|
||||
this.pendingDownloads.set(url, downloadPromise);
|
||||
|
||||
try {
|
||||
const result = await downloadPromise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingDownloads.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有缓存文件(App退出时调用)
|
||||
async clearCache(): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||
try {
|
||||
await fs.rm(join(this.cacheDir, entry));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.lruOrder.length = 0;
|
||||
console.log("[AvatarFileCache] Cache cleared");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 获取当前缓存的文件数量
|
||||
async getCacheCount(): Promise<number> {
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir);
|
||||
return entries.filter(
|
||||
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||
).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||
@@ -69,10 +69,34 @@ interface ConfigSchema {
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
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 加密的字段(普通模式)
|
||||
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_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
@@ -142,7 +166,22 @@ export class ConfigService {
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
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 = {
|
||||
@@ -690,7 +729,7 @@ export class ConfigService {
|
||||
// === 工具方法 ===
|
||||
|
||||
/**
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<EFBFBD><EFBFBD>置
|
||||
*/
|
||||
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||||
const wxid = this.get('myWxid')
|
||||
|
||||
@@ -63,6 +63,7 @@ type CachedImagePayload = {
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -116,7 +117,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -673,41 +676,53 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
|
||||
this.resolvedCache.set(imageDatName, datPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
return datPath
|
||||
}
|
||||
const normalized = this.normalizeDatBase(imageDatName)
|
||||
if (normalized !== imageDatName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
|
||||
this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
return normalizedPath
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1042,7 +1057,7 @@ export class ImageDecryptService {
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -1058,8 +1073,10 @@ export class ImageDecryptService {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
@@ -1070,9 +1087,13 @@ export class ImageDecryptService {
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
|
||||
@@ -8,11 +8,13 @@ type PreloadImagePayload = {
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
@@ -27,6 +29,7 @@ export class ImagePreloadService {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
@@ -34,7 +37,7 @@ export class ImagePreloadService {
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key, allowDecrypt })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
@@ -71,7 +74,8 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
|
||||
882
electron/services/insightService.ts
Normal file
882
electron/services/insightService.ts
Normal file
@@ -0,0 +1,882 @@
|
||||
/**
|
||||
* 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
|
||||
const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiInsightEnabled',
|
||||
'aiInsightScanIntervalHours',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
|
||||
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
}
|
||||
|
||||
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
return
|
||||
}
|
||||
this.scheduleSilenceScan()
|
||||
}
|
||||
|
||||
private clearRuntimeCache(): void {
|
||||
this.dbConnected = false
|
||||
this.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
this.lastActivityAnalysis.clear()
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
}
|
||||
if (this.silenceScanTimer !== null) {
|
||||
clearTimeout(this.silenceScanTimer)
|
||||
this.silenceScanTimer = null
|
||||
}
|
||||
if (this.silenceInitialDelayTimer !== null) {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 main.ts 在 addDbMonitorListener 回调中调用。
|
||||
* 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。
|
||||
* 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。
|
||||
*/
|
||||
handleDbMonitorChange(_type: string, _json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) return
|
||||
// 正在处理时忽略新事件,避免 timer 堆积
|
||||
if (this.processing) return
|
||||
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
}
|
||||
this.dbDebounceTimer = setTimeout(() => {
|
||||
this.dbDebounceTimer = null
|
||||
void this.analyzeRecentActivity()
|
||||
}, DB_CHANGE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测<><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 {
|
||||
this.clearTimers()
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
this.silenceScanTimer = setTimeout(async () => {
|
||||
this.silenceScanTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
this.silenceInitialDelayTimer = setTimeout(async () => {
|
||||
this.silenceInitialDelayTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, SILENCE_SCAN_INITIAL_DELAY_MS)
|
||||
}
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
insightLog('INFO', '沉默扫描:正在处理中,跳过本次')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
insightLog('INFO', '开始沉默联系人扫描...')
|
||||
try {
|
||||
const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS
|
||||
const thresholdMs = silenceDays * 24 * 60 * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
insightLog('INFO', `沉默阈值:${silenceDays} 天`)
|
||||
|
||||
// 沉默扫描间隔较长,强制刷新缓存以获取最新数据
|
||||
const sessions = await this.getSessionsCached(true)
|
||||
if (sessions.length === 0) {
|
||||
insightLog('WARN', '获取会话列表失败,跳过沉默扫描')
|
||||
return
|
||||
}
|
||||
|
||||
insightLog('INFO', `共 ${sessions.length} 个会话,开始过滤...`)
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
if (!this.isEnabled()) return
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
if (!this.isSessionAllowed(sessionId)) continue
|
||||
|
||||
const lastTimestamp = (session.lastTimestamp || 0) * 1000
|
||||
if (!lastTimestamp || lastTimestamp <= 0) continue
|
||||
|
||||
const silentMs = now - lastTimestamp
|
||||
if (silentMs < thresholdMs) continue
|
||||
|
||||
silentCount++
|
||||
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
|
||||
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'silence',
|
||||
silentDays
|
||||
})
|
||||
}
|
||||
insightLog('INFO', `沉默扫描完成,共发现 ${silentCount} 个沉默联系人`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `沉默扫描出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 活跃会话分析 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 在 DB 变更防抖后执行,分析最近活跃的会话。
|
||||
*
|
||||
* 触发条件(必须同时满足):
|
||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||
* 2. 该会话距上次活跃分析已超过冷却期
|
||||
*
|
||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||
*/
|
||||
private async analyzeRecentActivity(): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (this.processing) return
|
||||
|
||||
this.processing = true
|
||||
try {
|
||||
const now = Date.now()
|
||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
|
||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||
if (whitelistEnabled && whitelist.length > 0) {
|
||||
// 确保数据库已连接(首次时连接,之后复用)
|
||||
if (!this.dbConnected) {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) return
|
||||
this.dbConnected = true
|
||||
}
|
||||
|
||||
for (const sessionId of whitelist) {
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
|
||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
// 拉取最新 1 条消息,用时间戳判断是否有新消息,避免全量 getSessions()
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, 1)
|
||||
if (!msgsResult.success || !msgsResult.messages || msgsResult.messages.length === 0) continue
|
||||
|
||||
const latestMsg = msgsResult.messages[0]
|
||||
const latestTs = Number(latestMsg.createTime) || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
|
||||
if (latestTs <= lastSeen) continue // 没有新消息
|
||||
this.lastSeenTimestamp.set(sessionId, latestTs)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `白名单会话 ${sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
// displayName 使用白名单 sessionId,generateInsightForSession 内部会从上下文里获取真实名称
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: sessionId,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break // 每次最多处理 1 个会话
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||
const sessions = await this.getSessionsCached()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||
})
|
||||
|
||||
for (const session of privateSessions.slice(0, 10)) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId) continue
|
||||
|
||||
const currentTimestamp = session.lastTimestamp || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
if (currentTimestamp <= lastSeen) continue
|
||||
this.lastSeenTimestamp.set(sessionId, currentTimestamp)
|
||||
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `活跃分析出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 核心见解生成 ────────────────────────────────────────────────────────────
|
||||
|
||||
private async generateInsightForSession(params: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
triggerReason: 'activity' | 'silence'
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const apiBaseUrl = 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
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||
notif.show()
|
||||
} else {
|
||||
insightLog('WARN', '当前系统不支持原生通知')
|
||||
}
|
||||
|
||||
// 渠道二:Telegram Bot 推送(可选)
|
||||
const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean
|
||||
if (telegramEnabled) {
|
||||
const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || ''
|
||||
const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || ''
|
||||
if (telegramToken && telegramChatIds) {
|
||||
const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const telegramText = `【WeFlow】 ${notifTitle}\n\n${insight}`
|
||||
for (const chatId of chatIds) {
|
||||
this.sendTelegram(telegramToken, chatId, telegramText).catch((e) => {
|
||||
insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过')
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Telegram Bot API 发送消息。
|
||||
* 使用 Node 原生 https 模块,无需第三方依赖。
|
||||
*/
|
||||
private sendTelegram(token: string, chatId: string, text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' })
|
||||
const options = {
|
||||
hostname: 'api.telegram.org',
|
||||
port: 443,
|
||||
path: `/bot${token}/sendMessage`,
|
||||
method: 'POST' as const,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString()
|
||||
}
|
||||
}
|
||||
const req = https.request(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(parsed.description || '未知错误'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error(`响应解析失败: ${data.slice(0, 100)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) })
|
||||
req.on('error', reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,23 @@ export class KeyServiceLinux {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import dbus from "dbus-native";
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { app } from "electron";
|
||||
|
||||
const BUS_NAME = "org.freedesktop.Notifications";
|
||||
const OBJECT_PATH = "/org/freedesktop/Notifications";
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
@@ -18,173 +11,96 @@ export interface LinuxNotificationData {
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let sessionBus: dbus.DBusConnection | null = null;
|
||||
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
|
||||
const avatarCache: Map<string, string> = new Map();
|
||||
// 缓存目录
|
||||
let avatarCacheDir: string | null = null;
|
||||
|
||||
async function getSessionBus(): Promise<dbus.DBusConnection> {
|
||||
if (!sessionBus) {
|
||||
sessionBus = dbus.sessionBus();
|
||||
|
||||
// 挂载底层socket的error事件,防止掉线即可
|
||||
sessionBus.connection.on("error", (err: Error) => {
|
||||
console.error("[LinuxNotification] D-Bus connection error:", err);
|
||||
sessionBus = null; // 报错清理死对象
|
||||
});
|
||||
}
|
||||
return sessionBus;
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
// 确保缓存目录存在
|
||||
async function ensureCacheDir(): Promise<string> {
|
||||
if (!avatarCacheDir) {
|
||||
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
await fs.mkdir(avatarCacheDir, { recursive: true });
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[LinuxNotification] Failed to create avatar cache dir:",
|
||||
error,
|
||||
);
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
return avatarCacheDir;
|
||||
}
|
||||
|
||||
// 下载头像到本地临时文件
|
||||
async function downloadAvatarToLocal(url: string): Promise<string | null> {
|
||||
// 检查缓存
|
||||
if (avatarCache.has(url)) {
|
||||
return avatarCache.get(url) || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheDir = await ensureCacheDir();
|
||||
// 生成唯一文件名
|
||||
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
|
||||
const localPath = join(cacheDir, fileName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 微信 CDN 需要特殊的请求头才能下载图片
|
||||
const options = {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
Referer: "https://servicewechat.com/",
|
||||
Accept:
|
||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
};
|
||||
|
||||
const callback = (res: IncomingMessage) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.writeFile(localPath, buffer);
|
||||
avatarCache.set(url, localPath);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
res.on("error", reject);
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Download timeout"));
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`,
|
||||
);
|
||||
return localPath;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to download avatar:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLinuxNotification(
|
||||
data: LinuxNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
|
||||
const appName = "WeFlow";
|
||||
const replaceId = 0;
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
|
||||
// 处理头像:下载到本地或使用URL
|
||||
let appIcon = "";
|
||||
let hints: any[] = [];
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
// 优先尝试下载到本地
|
||||
const localPath = await downloadAvatarToLocal(data.avatarUrl);
|
||||
if (localPath) {
|
||||
hints = [["image-path", ["s", localPath]]];
|
||||
}
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
destination: BUS_NAME,
|
||||
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 notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
@@ -194,59 +110,22 @@ export async function showLinuxNotification(
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
return new Promise((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
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);
|
||||
}
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
try {
|
||||
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);
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
@@ -262,83 +141,34 @@ 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> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
|
||||
// 监听底层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);
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
} catch {}
|
||||
clearNotificationState(id);
|
||||
}
|
||||
|
||||
// 清理头像文件缓存
|
||||
try {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ export class WcdbCore {
|
||||
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
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 lastLogTail: string | null = null
|
||||
private lastResolvedLogPath: string | null = null
|
||||
@@ -277,7 +280,9 @@ export class WcdbCore {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const isArm64 = process.arch === 'arm64'
|
||||
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
|
||||
if (envDllPath && envDllPath.length > 0) {
|
||||
@@ -287,20 +292,33 @@ export class WcdbCore {
|
||||
// 基础路径探测
|
||||
const isPackaged = typeof process['resourcesPath'] !== 'undefined'
|
||||
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
|
||||
|
||||
const candidates = [
|
||||
// 环境变量指定 resource 目录
|
||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||
// 显式 setPaths 设置的路径
|
||||
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||
// 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)
|
||||
const roots = [
|
||||
process.env.WCDB_RESOURCES_PATH || null,
|
||||
this.resourcesPath || null,
|
||||
join(resourcesPath, 'resources'),
|
||||
resourcesPath,
|
||||
join(process.cwd(), 'resources')
|
||||
].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) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
@@ -1465,6 +1483,11 @@ export class WcdbCore {
|
||||
this.videoHardlinkCache.clear()
|
||||
}
|
||||
|
||||
private clearMediaStreamSessionCache(): void {
|
||||
this.mediaStreamSessionCache = null
|
||||
this.mediaStreamSessionCacheAt = 0
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ensureReady()
|
||||
}
|
||||
@@ -1580,6 +1603,7 @@ export class WcdbCore {
|
||||
this.currentDbStoragePath = null
|
||||
this.initialized = false
|
||||
this.clearHardlinkCaches()
|
||||
this.clearMediaStreamSessionCache()
|
||||
this.stopLogPolling()
|
||||
}
|
||||
}
|
||||
@@ -1957,7 +1981,7 @@ export class WcdbCore {
|
||||
error?: string
|
||||
}> {
|
||||
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 {
|
||||
const toInt = (value: unknown): number => {
|
||||
const n = Number(value || 0)
|
||||
@@ -2168,37 +2192,64 @@ export class WcdbCore {
|
||||
const offset = Math.max(0, toInt(options?.offset))
|
||||
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
|
||||
|
||||
const sessionsRes = await this.getSessions()
|
||||
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
|
||||
return { success: false, error: sessionsRes.error || '读取会话失败' }
|
||||
const getSessionRows = async (): Promise<{
|
||||
success: boolean
|
||||
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 || [])
|
||||
.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)
|
||||
let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
|
||||
if (requestedSessionId) {
|
||||
sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
|
||||
} else {
|
||||
const sessionsRowsRes = await getSessionRows()
|
||||
if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
|
||||
return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
|
||||
}
|
||||
sessionRows = sessionsRowsRes.rows
|
||||
}
|
||||
|
||||
const sessionRows = requestedSessionId
|
||||
? sessions.filter((row) => row.sessionId === requestedSessionId)
|
||||
: sessions
|
||||
if (sessionRows.length === 0) {
|
||||
return { success: true, items: [], hasMore: false, nextOffset: offset }
|
||||
}
|
||||
@@ -2219,10 +2270,10 @@ export class WcdbCore {
|
||||
outHasMore
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `扫描媒体流失败: ${result}` }
|
||||
return { success: false, error: `扫描资源失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析媒体流失败' }
|
||||
if (!jsonStr) return { success: false, error: '解析资源失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
|
||||
|
||||
@@ -2254,19 +2305,39 @@ export class WcdbCore {
|
||||
rawMessageContent &&
|
||||
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
|
||||
)
|
||||
const content = useRawMessageContent
|
||||
? rawMessageContent
|
||||
: decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||
const decodeContentIfNeeded = (): string => {
|
||||
if (useRawMessageContent) return rawMessageContent
|
||||
if (!rawMessageContent && !rawCompressContent) return ''
|
||||
return decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||
}
|
||||
const packedPayload = extractPackedPayload(row)
|
||||
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 videoMd5 = localType === 43
|
||||
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined)
|
||||
: undefined
|
||||
|
||||
let content = ''
|
||||
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 {
|
||||
sessionId,
|
||||
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
|
||||
@@ -2280,7 +2351,7 @@ export class WcdbCore {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
content: content || undefined
|
||||
content: localType === 43 ? (content || undefined) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
18
electron/types/dbus.d.ts
vendored
18
electron/types/dbus.d.ts
vendored
@@ -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;
|
||||
}
|
||||
@@ -27,6 +27,14 @@ export function destroyNotificationWindow() {
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (isLinux && linuxNotificationService) {
|
||||
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||
});
|
||||
linuxNotificationService = null;
|
||||
}
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
|
||||
1494
package-lock.json
generated
1494
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -24,7 +24,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"dbus-native": "^0.4.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -58,8 +57,8 @@
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite": "^8.0.7",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -99,7 +98,7 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -108,19 +107,19 @@
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
@@ -136,7 +135,7 @@
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/linux/install.sh",
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
@@ -191,7 +190,7 @@
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
Binary file not shown.
0
resources/xkey_helper_linux → resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
0
resources/xkey_helper_linux → resources/key/linux/x64/xkey_helper_linux
Executable file → Normal file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
0
resources/xkey_helper → resources/key/macos/universal/xkey_helper
Executable file → Normal file
0
resources/xkey_helper → resources/key/macos/universal/xkey_helper
Executable file → Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
Executable file → Normal file
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
Executable file → Normal file
Binary file not shown.
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
Binary file not shown.
15
src/App.tsx
15
src/App.tsx
@@ -339,6 +339,21 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 监听通知点击导航事件
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return
|
||||
|
||||
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
// 导航到聊天页面,通过URL参数让ChatPage接收sessionId
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
|
||||
@@ -1965,6 +1965,10 @@
|
||||
color: var(--on-primary);
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// 对方发送的消息 - 左侧白色
|
||||
@@ -1974,6 +1978,10 @@
|
||||
color: var(--text-primary);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&.system {
|
||||
@@ -2038,6 +2046,12 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
|
||||
.message-bubble:not(.system) .bubble-content {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
.message-bubble.emoji {
|
||||
.bubble-content {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
|
||||
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
selectSessionById
|
||||
])
|
||||
|
||||
// 监听URL参数中的sessionId,用于通知点击导航
|
||||
useEffect(() => {
|
||||
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
|
||||
const params = new URLSearchParams(location.search)
|
||||
const urlSessionId = params.get('sessionId')
|
||||
if (!urlSessionId) return
|
||||
if (!isConnected || isConnecting) return
|
||||
if (currentSessionId === urlSessionId) return
|
||||
selectSessionById(urlSessionId)
|
||||
// 选中后清除URL参数,避免影响后续用户手动切换会话
|
||||
navigate('/chat', { replace: true })
|
||||
}, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||
if (!isConnected || isConnecting) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { VirtuosoGrid } from 'react-virtuoso'
|
||||
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
|
||||
import './ResourcesPage.scss'
|
||||
|
||||
type MediaTab = 'image' | 'video'
|
||||
@@ -35,10 +36,14 @@ type DialogState = {
|
||||
onConfirm?: (() => void) | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 120
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36
|
||||
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4
|
||||
const PAGE_SIZE = 96
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
|
||||
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 { className = '', ...rest } = props
|
||||
@@ -409,7 +414,13 @@ function ResourcesPage() {
|
||||
}
|
||||
|
||||
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 streamResult = await window.electronAPI.chat.getMediaStream({
|
||||
sessionId: selectedContact === 'all' ? undefined : selectedContact,
|
||||
@@ -524,7 +535,6 @@ function ResourcesPage() {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
await window.electronAPI.chat.connect()
|
||||
const sessionResult = await window.electronAPI.chat.getSessions()
|
||||
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
|
||||
const initialNameMap: Record<string, string> = {}
|
||||
@@ -674,7 +684,10 @@ function ResourcesPage() {
|
||||
resolvingImageCacheBatchRef.current = true
|
||||
void (async () => {
|
||||
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 pathPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
@@ -741,7 +754,10 @@ function ResourcesPage() {
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
void window.electronAPI.image.preload(payloads, { allowDecrypt: false })
|
||||
void window.electronAPI.image.preload(payloads, {
|
||||
allowDecrypt: false,
|
||||
allowCacheIndex: false
|
||||
})
|
||||
}, [displayItems])
|
||||
|
||||
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
|
||||
@@ -813,14 +829,18 @@ function ResourcesPage() {
|
||||
if (!pending) return
|
||||
pendingRangeRef.current = null
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(pending.start - 8, pending.end + 32)
|
||||
resolveImageCacheRange(pending.start - 2, pending.end + 8)
|
||||
preloadImageCacheRange(pending.start - 4, pending.end + 20)
|
||||
resolveImageCacheRange(pending.start - 1, pending.end + 6)
|
||||
return
|
||||
}
|
||||
resolvePosterRange(pending.start, pending.end)
|
||||
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
|
||||
|
||||
const scheduleRangeResolve = useCallback((start: number, end: number) => {
|
||||
const previous = pendingRangeRef.current
|
||||
if (previous && start >= previous.start && end <= previous.end) {
|
||||
return
|
||||
}
|
||||
pendingRangeRef.current = { start, end }
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
@@ -832,8 +852,8 @@ function ResourcesPage() {
|
||||
useEffect(() => {
|
||||
if (displayItems.length === 0) return
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20))
|
||||
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
|
||||
return
|
||||
}
|
||||
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
|
||||
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
|
||||
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'other',
|
||||
title: '资源页图片批量解密',
|
||||
detail: `正在解密图片(0/${imageItems.length})`,
|
||||
progressText: `0 / ${imageItems.length}`,
|
||||
cancelable: false
|
||||
})
|
||||
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) {
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
failed += 1
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) continue
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
if (!result?.success) {
|
||||
failed += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (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) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
|
||||
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成')
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
|
||||
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 {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
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'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
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 }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
@@ -26,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||
{ id: 'insight', label: 'AI 见解', icon: Sparkles },
|
||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||
{ id: 'updates', label: '版本更新', icon: RefreshCw },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
@@ -123,7 +125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
setHttpApiToken(token)
|
||||
await configService.setHttpApiToken(token)
|
||||
showMessage('已生成并保存新的 Access Token', true)
|
||||
showMessage('已生成<EFBFBD><EFBFBD>保存新的 Access Token', true)
|
||||
}
|
||||
|
||||
const clearApiToken = async () => {
|
||||
@@ -213,6 +215,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
const checkWaylandStatus = async () => {
|
||||
@@ -438,6 +463,37 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
|
||||
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) {
|
||||
console.error('加载配置失败:', e)
|
||||
@@ -579,7 +635,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
|
||||
await handleCheckUpdate()
|
||||
} catch (e: any) {
|
||||
showMessage(`切换更新渠道失败: ${e}`, false)
|
||||
showMessage(`切换更新渠道<EFBFBD><EFBFBD>败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -820,16 +876,19 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'antiRevoke') return
|
||||
if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
// 两个 Tab 都需要会话列表;antiRevoke 还需要额外检查防撤回状态
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
if (activeTab === 'antiRevoke') {
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
}
|
||||
} catch (e: any) {
|
||||
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 (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
setImageKeyStatus('已获取图片<EFBFBD><EFBFBD>钥')
|
||||
showMessage('已自动获取图片密钥', true)
|
||||
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
|
||||
const newAesKey = result.aesKey
|
||||
@@ -2451,6 +2510,627 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
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(通过 @BotFather),Chat 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 = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -2552,7 +3232,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
value={`http://${httpApiHost}:${httpApiPort}`}
|
||||
readOnly
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
||||
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复<EFBFBD><EFBFBD><EFBFBD>">
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -2686,7 +3366,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
try {
|
||||
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
|
||||
if (!verifyResult.success) {
|
||||
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
|
||||
showMessage(verifyResult.error || 'Windows Hello <EFBFBD><EFBFBD>证失败', false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2918,7 +3598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
onClick={handleSetupHello}
|
||||
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
|
||||
>
|
||||
{isSettingHello ? '设置中...' : '开启与设置'}
|
||||
{isSettingHello ? '<EFBFBD><EFBFBD><EFBFBD>置中...' : '开启与设置'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -3135,6 +3815,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'insight' && renderInsightTab()}
|
||||
{activeTab === 'updates' && renderUpdatesTab()}
|
||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
|
||||
@@ -79,7 +79,24 @@ export const CONFIG_KEYS = {
|
||||
|
||||
// 数据收集
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 获取导出默认并发数
|
||||
// 获取导出默认并发<EFBFBD><EFBFBD>
|
||||
export async function getExportDefaultConcurrency(): Promise<number | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY)
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
|
||||
result: { success: number; fail: number }
|
||||
startTime: number
|
||||
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
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
|
||||
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,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
taskId: null,
|
||||
|
||||
startDecrypt: (total, sessionName) => set({
|
||||
isBatchDecrypting: true,
|
||||
progress: { current: 0, total },
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: Date.now(),
|
||||
sessionName
|
||||
}),
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
finishBackgroundTask(previousTaskId, 'canceled', {
|
||||
detail: '已被新的批量解密任务替换',
|
||||
progressText: '已替换'
|
||||
})
|
||||
}
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
const normalizedProgress = clampProgress(0, 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({
|
||||
isBatchDecrypting: false,
|
||||
showToast: false,
|
||||
showResultToast: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
set({
|
||||
isBatchDecrypting: true,
|
||||
progress: normalizedProgress,
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
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 }),
|
||||
setShowResultToast: (show) => set({ showResultToast: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: ''
|
||||
})
|
||||
}))
|
||||
reset: () => {
|
||||
const taskId = get().taskId
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '批量解密任务已重置',
|
||||
progressText: '已停止'
|
||||
})
|
||||
}
|
||||
|
||||
set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
taskId: null
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
13
src/types/electron.d.ts
vendored
13
src/types/electron.d.ts
vendored
@@ -78,6 +78,7 @@ export interface ElectronAPI {
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
|
||||
}
|
||||
log: {
|
||||
getPath: () => Promise<string>
|
||||
@@ -403,10 +404,16 @@ export interface ElectronAPI {
|
||||
|
||||
image: {
|
||||
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: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
@@ -414,7 +421,7 @@ export interface ElectronAPI {
|
||||
}>
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
|
||||
Reference in New Issue
Block a user