diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index fb63aff..6c3c813 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -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 diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 751d227..a6c7b56 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -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 diff --git a/.gitignore b/.gitignore index ae6f9bf..920d437 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,8 @@ Thumbs.db *.aps wcdb/ +!resources/wcdb/ +!resources/wcdb/** xkey/ server/ *info @@ -73,4 +75,4 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -Insight \ No newline at end of file +Insight diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 429a00f..6107dd2 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -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 diff --git a/electron/main.ts b/electron/main.ts index ce8f0ac..a3c0cf0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 }) @@ -1678,13 +1693,6 @@ function registerIpcHandlers() { return applyLaunchAtStartupPreference(enabled === true) }) - ipcMain.handle('app:checkWayland', async () => { - if (process.platform !== 'linux') return false; - - const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase(); - return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland'); - }) - ipcMain.handle('log:getPath', async () => { return join(app.getPath('userData'), 'logs', 'wcdb.log') }) @@ -2558,7 +2566,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 +2580,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 +2598,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 +3493,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 +3617,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') diff --git a/electron/preload.ts b/electron/preload.ts index 89575b6..48564f1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) } }, @@ -66,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) return () => ipcRenderer.removeAllListeners('app:updateAvailable') }, - checkWayland: () => ipcRenderer.invoke('app:checkWayland'), }, // 日志 @@ -266,15 +270,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 +502,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') } }) diff --git a/electron/services/avatarFileCacheService.ts b/electron/services/avatarFileCacheService.ts new file mode 100644 index 0000000..7216154 --- /dev/null +++ b/electron/services/avatarFileCacheService.ts @@ -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> = + 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 { + 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 { + 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 { + 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 { + 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((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 { + 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 { + 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 { + 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(); diff --git a/electron/services/config.ts b/electron/services/config.ts index 85da9fc..87029c9 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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 = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken']) +const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey']) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = 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 中取,找不到则回退到全局��置 */ getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { const wxid = this.get('myWxid') diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 37b41f2..34f755c 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -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 { - 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` ] diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index 2916bfe..05a772a 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -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 diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts new file mode 100644 index 0000000..3d7a092 --- /dev/null +++ b/electron/services/insightService.ts @@ -0,0 +1,871 @@ +/** + * insightService.ts + * + * AI 见解后台服务: + * 1. 监听 DB 变更事件(debounce 500ms 防抖,避免开机/重连时爆发大量事件阻塞主线程) + * 2. 沉默联系人扫描(独立 setInterval,每 4 小时一次) + * 3. 触发后拉取真实聊天上下文(若用户授权),组装 prompt 调用单一 AI 模型 + * 4. 输出 ≤80 字见解,通过现有 showNotification 弹出右下角通知 + * + * 设计原则: + * - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API + * - 所有失败静默处理,不影响主流程 + * - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制 + */ + +import https from 'https' +import http from 'http' +import { URL } from 'url' +import { Notification } from 'electron' +import { ConfigService } from './config' +import { chatService, ChatSession, Message } from './chatService' + +// ─── 常量 ──────────────────────────────────────────────────────────────────── + +/** + * DB 变更防抖延迟(毫秒)。 + * 设为 2s:微信写库通常是批量操作,500ms 过短会在开机/重连时产生大量连续触发。 + */ +const DB_CHANGE_DEBOUNCE_MS = 2000 + +/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */ +const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000 + +/** 单次 API 请求超时(毫秒) */ +const API_TIMEOUT_MS = 45_000 + +/** 沉默天数阈值默认值 */ +const DEFAULT_SILENCE_DAYS = 3 +const INSIGHT_CONFIG_KEYS = new Set([ + 'aiInsightEnabled', + 'aiInsightScanIntervalHours', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +// ─── 类型 ──────────────────────────────────────────────────────────────────── + +interface TodayTriggerRecord { + /** 该会话今日触发的时间戳列表(毫秒) */ + timestamps: number[] +} + +// ─── 日志 ───────────────────────────────────────────────────────────────────── + +/** + * 仅输出到 console,不落盘到文件。 + */ +function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void { + if (level === 'ERROR' || level === 'WARN') { + console.warn(`[InsightService] ${message}`) + } else { + console.log(`[InsightService] ${message}`) + } +} + +// ─── 工具函数 ───────────────────────────────────────────────────────────────── + +/** + * 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。 + * + * 例如: + * baseUrl = "https://api.ohmygpt.com/v1" + * path = "/chat/completions" + * 结果为 "https://api.ohmygpt.com/v1/chat/completions" + * + * 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)), + * 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地 + * 丢弃 v1,变成 https://api.example.com/chat/completions。 + */ +function buildApiUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠 + const suffix = path.startsWith('/') ? path : `/${path}` + return `${base}${suffix}` +} + +function getStartOfDay(date: Date = new Date()): number { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + return d.getTime() +} + +function formatTimestamp(ts: number): string { + return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) +} + +/** + * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 + * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 + */ +function callApi( + apiBaseUrl: string, + apiKey: string, + model: string, + messages: Array<{ role: string; content: string }>, + timeoutMs: number = API_TIMEOUT_MS +): Promise { + 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 = new Map() + private todayDate = getStartOfDay() + + /** + * 活跃分析冷却记录:sessionId -> 上次分析时间戳(毫秒) + * 同一会话 2 小时内不重复触发活跃分析,防止 DB 频繁变更时爆量调用 API。 + */ + private lastActivityAnalysis: Map = new Map() + + /** + * 跟踪每个会话上次见到的最新消息时间戳,用于判断是否有真正的新消息。 + * sessionId -> lastMessageTimestamp(秒,与微信 DB 保持一致) + */ + private lastSeenTimestamp: Map = new Map() + + /** + * 本地会话快照缓存,避免 analyzeRecentActivity 在每次 DB 变更时都做全量读取。 + * 首次调用时填充,此后只在沉默扫描里刷新(沉默扫描间隔更长,更合适做全量刷新)。 + */ + private sessionCache: ChatSession[] | null = null + /** sessionCache 最后刷新时间戳(ms),超过 15 分钟强制重新拉取 */ + private sessionCacheAt = 0 + /** 缓存 TTL 设为 15 分钟,大幅减少 connect() + getSessions() 调用频率 */ + private static readonly SESSION_CACHE_TTL_MS = 15 * 60 * 1000 + /** 数据库是否已连接(避免重复调用 chatService.connect()) */ + private dbConnected = false + + private started = false + + constructor() { + this.config = ConfigService.getInstance() + } + + // ── 公开 API ──────────────────────────────────────────────────────────────── + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + stop(): void { + const hadActiveFlow = + this.dbDebounceTimer !== null || + this.silenceScanTimer !== null || + this.silenceInitialDelayTimer !== null || + this.processing + this.started = false + this.clearTimers() + this.clearRuntimeCache() + this.processing = false + if (hadActiveFlow) { + insightLog('INFO', '已停止') + } + } + + async handleConfigChanged(key: string): Promise { + 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 { + if (!this.started) return + if (!this.isEnabled()) { + this.clearTimers() + this.clearRuntimeCache() + this.processing = false + return + } + this.scheduleSilenceScan() + } + + private clearRuntimeCache(): void { + this.dbConnected = false + this.sessionCache = null + this.sessionCacheAt = 0 + this.lastActivityAnalysis.clear() + this.lastSeenTimestamp.clear() + this.todayTriggers.clear() + this.todayDate = getStartOfDay() + } + + private clearTimers(): void { + if (this.dbDebounceTimer !== null) { + clearTimeout(this.dbDebounceTimer) + this.dbDebounceTimer = null + } + if (this.silenceScanTimer !== null) { + clearTimeout(this.silenceScanTimer) + this.silenceScanTimer = null + } + if (this.silenceInitialDelayTimer !== null) { + clearTimeout(this.silenceInitialDelayTimer) + this.silenceInitialDelayTimer = null + } + } + + /** + * 由 main.ts 在 addDbMonitorListener 回调中调用。 + * 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。 + * 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。 + */ + handleDbMonitorChange(_type: string, _json: string): void { + if (!this.started) return + if (!this.isEnabled()) return + // 正在处理时忽略新事件,避免 timer 堆积 + if (this.processing) return + + if (this.dbDebounceTimer !== null) { + clearTimeout(this.dbDebounceTimer) + } + this.dbDebounceTimer = setTimeout(() => { + this.dbDebounceTimer = null + void this.analyzeRecentActivity() + }, DB_CHANGE_DEBOUNCE_MS) + } + + /** + * 测��� API 连接,返回 { success, message }。 + * 供设置页"测试连接"按钮调用。 + */ + async testConnection(): Promise<{ success: boolean; message: string }> { + const apiBaseUrl = 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 { + const now = Date.now() + // 缓存命中:直接返回,零数据库操作 + if ( + !forceRefresh && + this.sessionCache !== null && + now - this.sessionCacheAt < InsightService.SESSION_CACHE_TTL_MS + ) { + return this.sessionCache + } + // 缓存未命中或强制刷新:连接数据库并拉取 + try { + // 只在首次或强制刷新时调用 connect(),避免重复建立连接 + if (!this.dbConnected || forceRefresh) { + const connectResult = await chatService.connect() + if (!connectResult.success) { + insightLog('WARN', '数据库连接失败,使用旧缓存') + return this.sessionCache ?? [] + } + this.dbConnected = true + } + const result = await chatService.getSessions() + if (result.success && result.sessions) { + this.sessionCache = result.sessions as ChatSession[] + this.sessionCacheAt = now + } + } catch (e) { + insightLog('WARN', `获取会话缓存失败: ${(e as Error).message}`) + // 连接可能已断开,下次强制重连 + this.dbConnected = false + } + return this.sessionCache ?? [] + } + + private resetIfNewDay(): void { + const todayStart = getStartOfDay() + if (todayStart > this.todayDate) { + this.todayDate = todayStart + this.todayTriggers.clear() + } + } + + /** + * 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。 + */ + private recordTrigger(sessionId: string): string[] { + this.resetIfNewDay() + const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] } + existing.timestamps.push(Date.now()) + this.todayTriggers.set(sessionId, existing) + return existing.timestamps.map(formatTimestamp) + } + + /** + * 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模���全局上下文。 + */ + private getTodayTotalTriggerCount(): number { + this.resetIfNewDay() + let total = 0 + for (const record of this.todayTriggers.values()) { + total += record.timestamps.length + } + return total + } + + // ── 沉默联系人扫描 ────────────────────────────────────────────────────────── + + private scheduleSilenceScan(): void { + this.clearTimers() + if (!this.started || !this.isEnabled()) return + + // 等待扫描完成后再安排下一次,避免并发堆积 + const scheduleNext = () => { + if (!this.started || !this.isEnabled()) return + const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4 + const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000 + insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`) + this.silenceScanTimer = setTimeout(async () => { + this.silenceScanTimer = null + await this.runSilenceScan() + scheduleNext() + }, intervalMs) + } + + this.silenceInitialDelayTimer = setTimeout(async () => { + this.silenceInitialDelayTimer = null + await this.runSilenceScan() + scheduleNext() + }, SILENCE_SCAN_INITIAL_DELAY_MS) + } + + private async runSilenceScan(): Promise { + 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 { + 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 { + 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 ─────────────���───────────────────────────────���──────────── + + // 今日触发统计(让模型具备时间与克制感) + 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 { + 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() diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 4b25c88..72c827c 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -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')) } diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 2c8aef9..85d5a36 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -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) { diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index e7642a9..c350eb1 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -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')) } diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts index 1e4bd22..111626c 100644 --- a/electron/services/linuxNotificationService.ts +++ b/electron/services/linuxNotificationService.ts @@ -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 = new Map(); +let notificationCounter = 1; +const activeNotifications: Map = new Map(); +const closeTimers: Map = new Map(); -// 头像缓存:url->localFilePath -const avatarCache: Map = new Map(); -// 缓存目录 -let avatarCacheDir: string | null = null; - -async function getSessionBus(): Promise { - 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 { - 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 { - // 检查缓存 - 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((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 { + 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 { - 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 { - 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 { 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((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 { + // 清理所有活动的通知 + for (const [id, notification] of activeNotifications) { + try { + notification.close(); + } catch {} + clearNotificationState(id); + } + + // 清理头像文件缓存 + try { + await avatarFileCache.clearCache(); + } catch {} + + console.log("[LinuxNotification] Service shutdown complete"); } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index dcf6dee..fde2ca7 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -121,6 +121,9 @@ export class WcdbCore { private videoHardlinkCache: Map = 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> : [] @@ -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 } }) diff --git a/electron/types/dbus.d.ts b/electron/types/dbus.d.ts deleted file mode 100644 index 9585a42..0000000 --- a/electron/types/dbus.d.ts +++ /dev/null @@ -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; -} diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index 587f43e..f3c8eca 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index 7f6e644..0c06ec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "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", @@ -45,7 +44,7 @@ "sharp": "^0.34.5", "typescript": "^6.0.2", "vite": "^7.3.2", - "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.6" } }, @@ -3084,25 +3083,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/abstract-socket": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz", - "integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "dependencies": { - "bindings": "^1.2.1", - "nan": "^2.12.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3615,16 +3595,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4459,27 +4429,6 @@ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, - "node_modules/dbus-native": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.4.0.tgz", - "integrity": "sha512-i3zvY3tdPEOaMgmK4riwupjDYRJ53rcE1Kj8rAgnLOFmBd0DekUih59qv8v+Oyils/U9p+s4sSsaBzHWLztI+Q==", - "license": "MIT", - "dependencies": { - "event-stream": "^4.0.0", - "hexy": "^0.2.10", - "long": "^4.0.0", - "optimist": "^0.6.1", - "put": "0.0.6", - "safe-buffer": "^5.1.1", - "xml2js": "^0.4.17" - }, - "bin": { - "dbus2js": "bin/dbus2js.js" - }, - "optionalDependencies": { - "abstract-socket": "^2.0.0" - } - }, "node_modules/debounce-fn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", @@ -4848,12 +4797,6 @@ "node": ">= 0.4" } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -5379,21 +5322,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/event-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", - "integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.1", - "from": "^0.1.7", - "map-stream": "0.0.7", - "pause-stream": "^0.0.11", - "split": "^1.0.1", - "stream-combiner": "^0.2.2", - "through": "^2.3.8" - } - }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -5570,13 +5498,6 @@ "node": ">= 6" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true - }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -5664,12 +5585,6 @@ "node": ">= 6" } }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", - "license": "MIT" - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -6069,15 +5984,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hexy": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", - "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", - "license": "MIT", - "bin": { - "hexy": "bin/hexy_cmd.js" - } - }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6806,12 +6712,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6874,12 +6774,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/map-stream": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", - "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", - "license": "MIT" - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8023,13 +7917,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nan": { - "version": "2.26.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", - "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", - "license": "MIT", - "optional": true - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8222,22 +8109,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", - "license": "MIT/X11", - "dependencies": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - } - }, - "node_modules/optimist/node_modules/minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==", - "license": "MIT" - }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -8387,18 +8258,6 @@ "dev": true, "license": "ISC" }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -8597,15 +8456,6 @@ "node": ">=6" } }, - "node_modules/put": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/put/-/put-0.0.6.tgz", - "integrity": "sha512-w0szIZ2NkqznMFqxYPRETCIi+q/S8UKis9F4yOl6/N9NDCZmbjZZT85aI4FgJf3vIPrzMPX60+odCLOaYxNWWw==", - "license": "MIT/X11", - "engines": { - "node": ">=0.3.0" - } - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -9467,18 +9317,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -9510,16 +9348,6 @@ "node": ">= 6" } }, - "node_modules/stream-combiner": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", - "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "through": "~2.3.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9788,12 +9616,6 @@ "utrie": "^1.0.2" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "license": "MIT" - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -10380,15 +10202,6 @@ "node": ">= 8" } }, - "node_modules/wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10432,28 +10245,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index fe7db7b..0f05abe 100644 --- a/package.json +++ b/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", @@ -59,7 +58,7 @@ "sharp": "^0.34.5", "typescript": "^6.0.2", "vite": "^7.3.2", - "vite-plugin-electron": "^0.28.8", + "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", diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll deleted file mode 100644 index 78747ac..0000000 Binary files a/resources/arm64/wcdb_api.dll and /dev/null differ diff --git a/resources/icon.icns b/resources/icons/macos/icon.icns similarity index 100% rename from resources/icon.icns rename to resources/icons/macos/icon.icns diff --git a/resources/linux/install.sh b/resources/installer/linux/install.sh similarity index 100% rename from resources/linux/install.sh rename to resources/installer/linux/install.sh diff --git a/resources/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux old mode 100755 new mode 100644 similarity index 100% rename from resources/xkey_helper_linux rename to resources/key/linux/x64/xkey_helper_linux diff --git a/resources/image_scan_entitlements.plist b/resources/key/macos/source/image_scan_entitlements.plist similarity index 100% rename from resources/image_scan_entitlements.plist rename to resources/key/macos/source/image_scan_entitlements.plist diff --git a/resources/image_scan_helper.c b/resources/key/macos/source/image_scan_helper.c similarity index 100% rename from resources/image_scan_helper.c rename to resources/key/macos/source/image_scan_helper.c diff --git a/resources/image_scan_helper b/resources/key/macos/universal/image_scan_helper old mode 100755 new mode 100644 similarity index 100% rename from resources/image_scan_helper rename to resources/key/macos/universal/image_scan_helper diff --git a/resources/libwx_key.dylib b/resources/key/macos/universal/libwx_key.dylib old mode 100755 new mode 100644 similarity index 100% rename from resources/libwx_key.dylib rename to resources/key/macos/universal/libwx_key.dylib diff --git a/resources/xkey_helper b/resources/key/macos/universal/xkey_helper old mode 100755 new mode 100644 similarity index 100% rename from resources/xkey_helper rename to resources/key/macos/universal/xkey_helper diff --git a/resources/xkey_helper_macos b/resources/key/macos/universal/xkey_helper_macos similarity index 100% rename from resources/xkey_helper_macos rename to resources/key/macos/universal/xkey_helper_macos diff --git a/resources/wx_key.dll b/resources/key/win32/x64/wx_key.dll similarity index 100% rename from resources/wx_key.dll rename to resources/key/win32/x64/wx_key.dll diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib deleted file mode 100755 index d185cfc..0000000 Binary files a/resources/libwcdb_api.dylib and /dev/null differ diff --git a/resources/libwcdb_api.so b/resources/libwcdb_api.so deleted file mode 100755 index d3c686a..0000000 Binary files a/resources/libwcdb_api.so and /dev/null differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib deleted file mode 100755 index 26b44d2..0000000 Binary files a/resources/macos/libwcdb_api.dylib and /dev/null differ diff --git a/resources/msvcp140.dll b/resources/runtime/win32/msvcp140.dll similarity index 100% rename from resources/msvcp140.dll rename to resources/runtime/win32/msvcp140.dll diff --git a/resources/msvcp140_1.dll b/resources/runtime/win32/msvcp140_1.dll similarity index 100% rename from resources/msvcp140_1.dll rename to resources/runtime/win32/msvcp140_1.dll diff --git a/resources/vcruntime140.dll b/resources/runtime/win32/vcruntime140.dll similarity index 100% rename from resources/vcruntime140.dll rename to resources/runtime/win32/vcruntime140.dll diff --git a/resources/vcruntime140_1.dll b/resources/runtime/win32/vcruntime140_1.dll similarity index 100% rename from resources/vcruntime140_1.dll rename to resources/runtime/win32/vcruntime140_1.dll diff --git a/resources/linux/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so old mode 100755 new mode 100644 similarity index 66% rename from resources/linux/libwcdb_api.so rename to resources/wcdb/linux/x64/libwcdb_api.so index 0fa218c..8f698f3 Binary files a/resources/linux/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/macos/libWCDB.dylib b/resources/wcdb/macos/universal/libWCDB.dylib old mode 100755 new mode 100644 similarity index 100% rename from resources/macos/libWCDB.dylib rename to resources/wcdb/macos/universal/libWCDB.dylib diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib new file mode 100644 index 0000000..5a81c68 Binary files /dev/null and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/arm64/WCDB.dll b/resources/wcdb/win32/arm64/WCDB.dll similarity index 100% rename from resources/arm64/WCDB.dll rename to resources/wcdb/win32/arm64/WCDB.dll diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll new file mode 100644 index 0000000..5f144d8 Binary files /dev/null and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/SDL2.dll b/resources/wcdb/win32/x64/SDL2.dll similarity index 100% rename from resources/SDL2.dll rename to resources/wcdb/win32/x64/SDL2.dll diff --git a/resources/WCDB.dll b/resources/wcdb/win32/x64/WCDB.dll similarity index 100% rename from resources/WCDB.dll rename to resources/wcdb/win32/x64/WCDB.dll diff --git a/resources/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll similarity index 100% rename from resources/wcdb_api.dll rename to resources/wcdb/win32/x64/wcdb_api.dll diff --git a/src/App.tsx b/src/App.tsx index f54442d..c9c574b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,44 +107,6 @@ function App() { const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) const [analyticsConsent, setAnalyticsConsent] = useState(null) - const [showWaylandWarning, setShowWaylandWarning] = useState(false) - - useEffect(() => { - const checkWaylandStatus = async () => { - try { - // 防止在非客户端环境报错,先检查 API 是否存在 - if (!window.electronAPI?.app?.checkWayland) return - - // 通过 configService 检查是否已经弹过窗 - const hasWarned = await window.electronAPI.config.get('waylandWarningShown') - - if (!hasWarned) { - const isWayland = await window.electronAPI.app.checkWayland() - if (isWayland) { - setShowWaylandWarning(true) - } - } - } catch (e) { - console.error('检查 Wayland 状态失败:', e) - } - } - - // 只有在协议同意之后并且已经进入主应用流程才检查 - if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) { - checkWaylandStatus() - } - }, [isAgreementWindow, isOnboardingWindow, agreementLoading]) - - const handleDismissWaylandWarning = async () => { - try { - // 记录到本地配置中,下次不再提示 - await window.electronAPI.config.set('waylandWarningShown', true) - } catch (e) { - console.error('保存 Wayland 提示状态失败:', e) - } - setShowWaylandWarning(false) - } - useEffect(() => { if (location.pathname !== '/settings') { settingsBackgroundRef.current = location @@ -339,6 +301,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) { @@ -670,33 +647,6 @@ function App() { )} - {/*{showWaylandWarning && (*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/*

环境兼容性提示 (Wayland)

*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*

检测到您当前正在使用 Wayland 显示服务器。

*/} - {/*

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

*/} - {/*

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

*/} - {/*
*/} - {/*

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

*/} - {/*

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

*/} - {/*

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*)}*/} - {/* 更新提示对话框 */} 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) { diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index f58c729..7518647 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -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>(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 = {} @@ -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 = {} const updatePatch: Record = {} @@ -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 => { @@ -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 = {} const updatePatch: Record = {} + 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) } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 316228c..2d6c3f2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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('已生成��保存新的 Access Token', true) } const clearApiToken = async () => { @@ -213,22 +215,28 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache - const [isWayland, setIsWayland] = useState(false) - useEffect(() => { - const checkWaylandStatus = async () => { - if (window.electronAPI?.app?.checkWayland) { - try { - const wayland = await window.electronAPI.app.checkWayland() - setIsWayland(wayland) - } catch (e) { - console.error('检查 Wayland 状态失败:', e) - } - } - } - checkWaylandStatus() - }, []) - - + // 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>(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('') // 检查 Hello 可用性 useEffect(() => { @@ -438,6 +446,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 +618,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) await handleCheckUpdate() } catch (e: any) { - showMessage(`切换更新渠道失败: ${e}`, false) + showMessage(`切换更新渠道��败: ${e}`, false) } } @@ -820,16 +859,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 +1213,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('已获取图片��钥') showMessage('已自动获取图片密钥', true) const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newAesKey = result.aesKey @@ -1637,11 +1679,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
选择通知弹窗在屏幕上的显示位置 - {isWayland && ( - - ⚠️ 注意:Wayland 环境下该配置可能无效! - - )}
{ + 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 = () => ( +
+ {/* 总开关 */} +
+ + + 开启后,AI 会在后台默默分析聊天数据,在合适的时机通过右下角弹窗送出一针见血的见解——例如提醒你久未联系的朋友,或对你刚刚的对话提出回复建议。默认关闭,所有分析均在本地发起请求,不经过任何第三方中间服务。 + +
+ {aiInsightEnabled ? '已开启' : '已关闭'} + +
+
+ +
+ + {/* API 配置 */} +
+ + + 填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。 + 程序会自动拼接 /chat/completions。 +
+ 示例:https://api.ohmygpt.com/v1https://api.openai.com/v1 +
+ { + const val = e.target.value + setAiInsightApiBaseUrl(val) + scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val)) + }} + style={{ fontFamily: 'monospace' }} + /> +
+ +
+ + + 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。 + +
+ { + const val = e.target.value + setAiInsightApiKey(val) + scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val)) + }} + style={{ flex: 1, fontFamily: 'monospace' }} + /> + + {aiInsightApiKey && ( + + )} +
+
+ +
+ + + 填写你的 API 提供商支持的模型名,建议使用综合能力较强的模型以获得有洞察力的见解。 +
+ 常用示例:gpt-4o-minigpt-4odeepseek-chatclaude-3-5-haiku-20241022 +
+ { + const val = e.target.value.trim() || 'gpt-4o-mini' + setAiInsightApiModel(val) + scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val)) + }} + style={{ width: 260, fontFamily: 'monospace' }} + /> +
+ + {/* 测试连接 + 触发测试 */} +
+ + + 先用"测试 API 连接"确认 Key 和 URL 填写正确,再用"立即触发测试见解"验证完整链路(数据库→API→弹窗)。触发后请留意右下角通知弹窗。 + +
+ {/* 测试 API 连接 */} +
+ + {insightTestResult && ( + + {insightTestResult.success ? : } + {insightTestResult.message} + + )} +
+ {/* 触发测试见解 */} +
+ + {insightTriggerResult && ( + + {insightTriggerResult.success ? : } + {insightTriggerResult.message} + + )} +
+
+
+ +
+ + {/* 行为配置 */} +
+ + + 有新消息时触发活跃分析的冷却时间。设为 0 表示无冷却,每条新消息都可能触发见解(AI 言论自由模式)。建议按需调整,费用自理。 + + { + const val = Math.max(0, parseInt(e.target.value, 10) || 0) + setAiInsightCooldownMinutes(val) + scheduleConfigSave('aiInsightCooldownMinutes', () => configService.setAiInsightCooldownMinutes(val)) + }} + style={{ width: 120 }} + /> + {aiInsightCooldownMinutes === 0 && ( + + 无冷却 — 每次 DB 变更均可触发 + + )} +
+ +
+ + + 多久扫描一次沉默联系人。重启生效。最小 0.1 小时(6 分钟)。 + + { + const val = Math.max(0.1, parseFloat(e.target.value) || 4) + setAiInsightScanIntervalHours(val) + scheduleConfigSave('aiInsightScanIntervalHours', () => configService.setAiInsightScanIntervalHours(val)) + }} + style={{ width: 120 }} + /> +
+ +
+ + + 与某私聊联系人超过此天数没有消息往来时,触发沉默类见解。 + + { + const val = Math.max(1, parseInt(e.target.value, 10) || 3) + setAiInsightSilenceDays(val) + scheduleConfigSave('aiInsightSilenceDays', () => configService.setAiInsightSilenceDays(val)) + }} + style={{ width: 100 }} + /> +
+ +
+ + + 开启后,触发见解时会将该联系人最近 N 条聊天记录发送给 AI,分析质量显著提升。 +
+ 关闭时:AI 仅知道统计摘要(沉默天数等),输出质量较低。 +
+ 开启时:聊天文本内容(不含图片、语音)会通过你配置的 API 发送给模型提供商。请确认你信任该服务商。 +
+
+ {aiInsightAllowContext ? '已授权' : '未授权'} + +
+
+ + {aiInsightAllowContext && ( +
+ + + 发送给 AI 的聊天记录最大条数。条数越多分析越准确,token 消耗也越多。 + + { + const val = Math.max(1, Math.min(200, parseInt(e.target.value, 10) || 40)) + setAiInsightContextCount(val) + scheduleConfigSave('aiInsightContextCount', () => configService.setAiInsightContextCount(val)) + }} + style={{ width: 100 }} + /> +
+ )} + +
+ + {/* 自定义 System Prompt */} + {(() => { + const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 + +要求: +1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。 +2. 控制在 80 字以内,直接、具体、一针见血。不要废话。 +3. 输出纯文本,不使用 Markdown。 +4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。` + + // 展示值:有自定义内容时显示自定义内容,否则显示默认值(可直接编辑) + const displayValue = aiInsightSystemPrompt || DEFAULT_SYSTEM_PROMPT + + return ( +
+
+ + +
+ + 当前显示内置默认提示词,可直接编辑修改。修改后立即生效,无需重启。可变的统计信息(触发次数、对话内容)会自动附加在用户消息里,无需在此填写。 + +