diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe14ef8..917a8b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -350,6 +350,8 @@ jobs: updpkgsums: true assets: | resources/installer/linux/weflow.desktop + resources/installer/linux/icon.png + resources/installer/linux/.gitignore ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_username: H3CoF6 diff --git a/electron/main.ts b/electron/main.ts index b57b76b..3c574df 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -34,6 +34,7 @@ import { insightService } from './services/insightService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' import { backupService } from './services/backupService' +import { imageDownloadService } from './services/imageDownloadService' // 配置自动更新 autoUpdater.autoDownload = false @@ -3954,6 +3955,19 @@ function registerIpcHandlers() { } }) + // 自动下载原图 + ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => { + return await imageDownloadService.startAutoDownload(whitelist || []) + }) + + ipcMain.handle('image:stopAutoDownload', async () => { + await imageDownloadService.stopAutoDownload() + return { success: true } + }) + + ipcMain.handle('image:getAutoDownloadStatus', async () => { + return await imageDownloadService.getStatus() + }) } // 主窗口引用 @@ -4081,6 +4095,13 @@ app.whenReady().then(async () => { // 注册 IPC 处理器 updateSplashProgress(28, '正在初始化...') registerIpcHandlers() + if (configService.get('autoDownloadHighRes')) { + const whitelistArr = configService.get('autoDownloadWhitelist') || [] + const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0) + ? (whitelistArr.join('\0') + '\0\0') + : '' + imageDownloadService.startAutoDownload(whitelistStr) + } chatService.addDbMonitorListener((type, json) => { messagePushService.handleDbMonitorChange(type, json) insightService.handleDbMonitorChange(type, json) @@ -4252,6 +4273,8 @@ const shutdownAppServices = async (): Promise => { }, 5000) forceExitTimer.unref() try { await cloudControlService.stop() } catch {} + // 停止自动下载服务 + try { await imageDownloadService.stopAutoDownload() } catch {} // 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调 try { chatService.close() } catch {} // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 diff --git a/electron/preload.ts b/electron/preload.ts index c7ba7c2..562d968 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -365,7 +365,10 @@ contextBridge.exposeInMainWorld('electronAPI', { }) => callback(payload) ipcRenderer.on('image:decryptProgress', listener) return () => ipcRenderer.removeListener('image:decryptProgress', listener) - } + }, + startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist), + stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'), + getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus') }, // 视频 @@ -374,6 +377,11 @@ contextBridge.exposeInMainWorld('electronAPI', { parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) }, + process: { + platform: process.platform, + arch: process.arch + }, + // 数据分析 analytics: { getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 7965fd0..628ecf0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -6,6 +6,7 @@ import * as https from 'https' import * as http from 'http' import * as fzstd from 'fzstd' import * as crypto from 'crypto' +import { app, BrowserWindow, dialog } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' @@ -17,7 +18,6 @@ import { voiceTranscribeService } from './voiceTranscribeService' import { ImageDecryptService } from './imageDecryptService' import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData' import { LRUCache } from '../utils/LRUCache.js' -import { getAppPathFallback, getElectronBrowserWindow, getElectronDialog, getPathFallback, isElectronAppPackaged } from './electronRuntime' export interface ChatSession { username: string @@ -498,7 +498,7 @@ class ChatService { } private async maybeShowInitFailureDialog(errorMessage: string): Promise { - if (!isElectronAppPackaged()) return + if (!app.isPackaged) return if (this.initFailureDialogShown) return const code = this.extractErrorCode(errorMessage) @@ -519,8 +519,6 @@ class ChatService { ].join('\n') try { - const dialog = getElectronDialog() - if (!dialog?.showMessageBox) return await dialog.showMessageBox({ type: 'error', title: 'WeFlow 启动失败', @@ -602,7 +600,7 @@ class ChatService { console.error('[ChatService] 数据库监听回调失败:', error) } } - const windows = getElectronBrowserWindow()?.getAllWindows?.() || [] + const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 windows.forEach((win) => { if (!win.isDestroyed()) { @@ -7182,7 +7180,7 @@ class ChatService { return join(cachePath, 'Voices') } // 回退到默认目录 - const documentsPath = getPathFallback('documents') + const documentsPath = app.getPath('documents') return join(documentsPath, 'WeFlow', 'Voices') } @@ -7192,7 +7190,7 @@ class ChatService { return join(cachePath, 'Emojis') } // 回退到默认目录 - const documentsPath = getPathFallback('documents') + const documentsPath = app.getPath('documents') return join(documentsPath, 'WeFlow', 'Emojis') } @@ -8437,13 +8435,13 @@ class ChatService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { let wasmPath: string - if (isElectronAppPackaged()) { + if (app.isPackaged) { wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') if (!existsSync(wasmPath)) { wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } } else { - wasmPath = join(getAppPathFallback(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } if (!existsSync(wasmPath)) { @@ -8631,7 +8629,7 @@ class ChatService { /** 获取持久化转写缓存文件路径 */ private getTranscriptCachePath(): string { const cachePath = this.configService.get('cachePath') - const base = cachePath || join(getPathFallback('documents'), 'WeFlow') + const base = cachePath || join(app.getPath('documents'), 'WeFlow') return join(base, 'Voices', 'transcripts.json') } diff --git a/electron/services/config.ts b/electron/services/config.ts index 16ece13..2973e2d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,14 +1,13 @@ -import { dirname, join } from 'path' +import { join } from 'path' +import { app, safeStorage } from 'electron' import crypto from 'crypto' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import Store from 'electron-store' import { expandHomePath } from '../utils/pathUtils' -import { getElectronSafeStorage, getPathFallback, isWorkerRuntime } from './electronRuntime' // 加密前缀标记 const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) const isSafeStorageAvailable = (): boolean => { try { - const safeStorage = getElectronSafeStorage() return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable() } catch { return false @@ -86,7 +85,13 @@ interface ConfigSchema { aiInsightApiModel: string aiInsightSilenceDays: number aiInsightAllowContext: boolean + aiInsightAllowMomentsContext: boolean + aiInsightMomentsContextCount: number + aiInsightMomentsBindings: Record aiInsightAllowSocialContext: boolean + aiInsightSocialContextCount: number + aiInsightWeiboCookie: string + aiInsightWeiboBindings: Record aiInsightFilterMode: 'whitelist' | 'blacklist' aiInsightFilterList: string[] aiInsightWhitelistEnabled: boolean @@ -111,68 +116,8 @@ interface ConfigSchema { aiFootprintSystemPrompt: string /** 是否将 AI 见解调试日志输出到桌面 */ aiInsightDebugLogEnabled: boolean -} - -interface ConfigStoreLike> { - get(key: K): T[K] - set(key: K, value: T[K]): void - clear(): void - store: T -} - -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) -} - -class JsonConfigStore> implements ConfigStoreLike { - private readonly filePath: string - private readonly defaults: T - private data: T - - constructor(options: { name: string; defaults: T; cwd?: string }) { - const baseDir = options.cwd || getPathFallback('userData') - mkdirSync(baseDir, { recursive: true }) - this.filePath = join(baseDir, `${options.name}.json`) - this.defaults = cloneJson(options.defaults) - this.data = cloneJson(options.defaults) - this.load() - } - - get store(): T { - return this.data - } - - private load(): void { - try { - if (!existsSync(this.filePath)) return - const raw = readFileSync(this.filePath, 'utf8') - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - this.data = { ...cloneJson(this.defaults), ...parsed } - } - } catch { - this.data = cloneJson(this.defaults) - } - } - - private persist(): void { - mkdirSync(dirname(this.filePath), { recursive: true }) - writeFileSync(this.filePath, JSON.stringify(this.data), 'utf8') - } - - get(key: K): T[K] { - return this.data[key] - } - - set(key: K, value: T[K]): void { - this.data[key] = value - this.persist() - } - - clear(): void { - this.data = cloneJson(this.defaults) - this.persist() - } + autoDownloadHighRes: boolean + autoDownloadWhitelist: string[] } // 需要 safeStorage 加密的字段(普通模式) @@ -194,7 +139,7 @@ const LOCKABLE_NUMBER_KEYS: Set = new Set(['imageXorKey']) export class ConfigService { private static instance: ConfigService - private store!: ConfigStoreLike + private store!: Store // 锁定模式运行时状态 private unlockedKeys: Map = new Map() @@ -268,6 +213,9 @@ export class ConfigService { aiInsightApiModel: 'gpt-4o-mini', aiInsightSilenceDays: 3, aiInsightAllowContext: false, + aiInsightAllowMomentsContext: false, + aiInsightMomentsContextCount: 5, + aiInsightMomentsBindings: {}, aiInsightAllowSocialContext: false, aiInsightFilterMode: 'whitelist', aiInsightFilterList: [], @@ -285,20 +233,41 @@ export class ConfigService { aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', - aiInsightDebugLogEnabled: false + aiInsightDebugLogEnabled: false, + autoDownloadHighRes: false, + autoDownloadWhitelist: [] } - const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() - this.store = new JsonConfigStore({ + const storeOptions: any = { name: 'WeFlow-config', defaults, - cwd: cwd || undefined - }) - - if (!isWorkerRuntime()) { - this.migrateAuthFields() - this.migrateAiConfig() + projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' } + const runningInWorker = process.env.WEFLOW_WORKER === '1' + if (runningInWorker) { + const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() + if (cwd) { + storeOptions.cwd = cwd + } + } + + try { + this.store = new Store(storeOptions) + } catch (error) { + const message = String((error as Error)?.message || error || '') + if (message.includes('projectName')) { + const fallbackOptions = { + ...storeOptions, + projectName: 'WeFlow', + cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd() + } + this.store = new Store(fallbackOptions) + } else { + throw error + } + } + this.migrateAuthFields() + this.migrateAiConfig() } // === 状态查询 === @@ -400,8 +369,6 @@ export class ConfigService { if (!plaintext) return '' if (plaintext.startsWith(SAFE_PREFIX)) return plaintext if (!isSafeStorageAvailable()) return plaintext - const safeStorage = getElectronSafeStorage() - if (!safeStorage) return plaintext const encrypted = safeStorage.encryptString(plaintext) return SAFE_PREFIX + encrypted.toString('base64') } @@ -410,8 +377,6 @@ export class ConfigService { if (!stored) return '' if (!stored.startsWith(SAFE_PREFIX)) return stored if (!isSafeStorageAvailable()) return '' - const safeStorage = getElectronSafeStorage() - if (!safeStorage) return '' try { const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') return safeStorage.decryptString(buf) @@ -879,7 +844,7 @@ export class ConfigService { if (workerUserDataPath) { return workerUserDataPath } - return getPathFallback('userData') + return app?.getPath?.('userData') || process.cwd() } getCacheBasePath(): string { diff --git a/electron/services/contactCacheService.ts b/electron/services/contactCacheService.ts index 5cf1ecf..a481227 100644 --- a/electron/services/contactCacheService.ts +++ b/electron/services/contactCacheService.ts @@ -1,5 +1,6 @@ import { join, dirname } from 'path' import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' export interface ContactCacheEntry { diff --git a/electron/services/electronRuntime.ts b/electron/services/electronRuntime.ts deleted file mode 100644 index ea6aeea..0000000 --- a/electron/services/electronRuntime.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { homedir, tmpdir } from 'os' -import { join } from 'path' - -type RuntimeRequire = (id: string) => any - -let cachedElectron: any | null | false = null - -export function isWorkerRuntime(): boolean { - return process.env.WEFLOW_WORKER === '1' -} - -export function getElectronModule(): any | null { - if (isWorkerRuntime()) return null - if (cachedElectron !== null) return cachedElectron || null - try { - const runtimeRequire = (0, eval)('require') as RuntimeRequire - cachedElectron = runtimeRequire('electron') - } catch { - cachedElectron = false - } - return cachedElectron || null -} - -export function getElectronApp(): any | null { - return getElectronModule()?.app || null -} - -export function getElectronBrowserWindow(): any | null { - return getElectronModule()?.BrowserWindow || null -} - -export function getElectronDialog(): any | null { - return getElectronModule()?.dialog || null -} - -export function getElectronSafeStorage(): any | null { - return getElectronModule()?.safeStorage || null -} - -export function getElectronPath(name: string): string | null { - try { - const getter = getElectronApp()?.getPath - if (typeof getter === 'function') { - return getter(name) - } - } catch { - // fall through to caller fallback - } - return null -} - -export function getAppPathFallback(): string { - try { - const getter = getElectronApp()?.getAppPath - if (typeof getter === 'function') { - return getter() - } - } catch { - // fall through - } - return process.cwd() -} - -export function getPathFallback(name: string): string { - const fromElectron = getElectronPath(name) - if (fromElectron) return fromElectron - - const home = homedir() - switch (name) { - case 'userData': { - const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - if (workerUserDataPath) return workerUserDataPath - if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'WeFlow') - if (process.platform === 'darwin') return join(home, 'Library', 'Application Support', 'WeFlow') - return join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'WeFlow') - } - case 'documents': - return join(home, 'Documents') - case 'desktop': - return join(home, 'Desktop') - case 'downloads': - return join(home, 'Downloads') - case 'temp': - return tmpdir() - case 'appData': - return process.platform === 'win32' && process.env.APPDATA ? process.env.APPDATA : join(home, '.config') - default: - return process.cwd() - } -} - -export function isElectronAppPackaged(): boolean { - const app = getElectronApp() - if (typeof app?.isPackaged === 'boolean') return app.isPackaged - return Boolean((process as any).resourcesPath && process.env.NODE_ENV !== 'development') -} diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts index 86e23f2..5ff1049 100644 --- a/electron/services/exportRecordService.ts +++ b/electron/services/exportRecordService.ts @@ -1,6 +1,6 @@ +import { app } from 'electron' import fs from 'fs' import path from 'path' -import { getPathFallback } from './electronRuntime' export interface ExportRecord { exportTime: number @@ -20,7 +20,7 @@ class ExportRecordService { private resolveFilePath(): string { if (this.filePath) return this.filePath const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() - const userDataPath = workerUserDataPath || getPathFallback('userData') + const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() fs.mkdirSync(userDataPath, { recursive: true }) this.filePath = path.join(userDataPath, 'weflow-export-records.json') return this.filePath diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index badca5f..66552b4 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -1,3 +1,4 @@ +import { app, BrowserWindow } from 'electron' import { basename, dirname, extname, join } from 'path' import { pathToFileURL } from 'url' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs' @@ -7,7 +8,6 @@ import crypto from 'crypto' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt' -import { getElectronBrowserWindow, getPathFallback, isElectronAppPackaged } from './electronRuntime' // 获取 ffmpeg-static 的路径 function getStaticFfmpegPath(): string | null { @@ -35,7 +35,7 @@ function getStaticFfmpegPath(): string | null { } // 方法3: 打包后的路径 - if (isElectronAppPackaged()) { + if (app?.isPackaged) { const resourcesPath = process.resourcesPath const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe') if (existsSync(packedPath)) { @@ -1475,7 +1475,7 @@ export class ImageDecryptService { private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> { try { - const getter = (getElectronBrowserWindow() as { getAllWindows?: () => any[] } | undefined)?.getAllWindows + const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows if (typeof getter !== 'function') return [] const windows = getter() if (!Array.isArray(windows)) return [] @@ -2191,7 +2191,14 @@ export class ImageDecryptService { } private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null { - return getPathFallback(name) + try { + const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath + if (typeof getter !== 'function') return null + const value = getter(name) + return typeof value === 'string' && value.trim() ? value : null + } catch { + return null + } } private getUserDataPath(): string { diff --git a/electron/services/imageDownloadService.ts b/electron/services/imageDownloadService.ts new file mode 100644 index 0000000..78eff18 --- /dev/null +++ b/electron/services/imageDownloadService.ts @@ -0,0 +1,203 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync } from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' +// import { ConfigService } from './config' + +const execFileAsync = promisify(execFile) + +export class ImageDownloadService { + private static instance: ImageDownloadService + private koffi: any = null + private lib: any = null + private initialized = false + + private initImgHelper: any = null + private uninstallImgHelper: any = null + private getImgHelperError: any = null + + private currentPid: number | null = null + private pollTimer: NodeJS.Timeout | null = null + private isHooked = false + + private lastWhitelist: string[] = [] + + static getInstance(): ImageDownloadService { + if (!ImageDownloadService.instance) { + ImageDownloadService.instance = new ImageDownloadService() + } + return ImageDownloadService.instance + } + + private constructor() { + } + + private async ensureInitialized(): Promise { + if (this.initialized) return true + if (process.platform !== 'win32' || process.arch !== 'x64') return false + + try { + this.koffi = require('koffi') + const dllPath = this.getDllPath() + if (!existsSync(dllPath)) return false + + this.lib = this.koffi.load(dllPath) + + this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)') + this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()') + this.getImgHelperError = this.lib.func('const char* GetImgHelperError()') + + this.initialized = true + return true + } catch (error) { + console.error('[ImageDownloadService] failed to initialize:', error) + return false + } + } + + private getDllPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll')) + } else { + candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + return candidates[0] + } + + private async findMainWeChatPid(): Promise { + try { + const script = ` + Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" | + Select-Object ProcessId, CommandLine | + ConvertTo-Json -Compress + `; + + const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script]) + if (!stdout || !stdout.trim()) return null + + let processes = JSON.parse(stdout.trim()) + if (!Array.isArray(processes)) processes = [processes] + + const target = processes + .filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe')) + .sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0] + + return target ? target.ProcessId : null; + } catch (e) { + return null + } + } + + async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> { + if (!await this.ensureInitialized()) { + return { success: false, error: '核心组件初始化失败' } + } + + if (this.isHooked) { + await this.unhook() + } + + this.lastWhitelist = whitelist + + if (!this.pollTimer) { + this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000) + } + + return await this.checkAndHook(whitelist, true) + } + + async stopAutoDownload() { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + await this.unhook() + } + + private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> { + const pid = await this.findMainWeChatPid() + + if (!pid) { + if (this.isHooked) { + console.log('[ImageDownloadService] WeChat exited, unhooking') + await this.unhook() + } + return { success: true, error: '等待微信启动' } + } + + if (this.isHooked && this.currentPid === pid) { + return { success: true } + } + + if (this.isHooked && this.currentPid !== pid) { + console.log('[ImageDownloadService] WeChat PID changed, re-hooking') + await this.unhook() + } + + console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`) + try { + let whitelistBuffer: Buffer | null = null; + if (typeof whitelist === 'string') { + if (whitelist.length > 0) { + whitelistBuffer = Buffer.from(whitelist, 'utf8'); + } + } else if (Array.isArray(whitelist) && whitelist.length > 0) { + whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8'); + } + + const success = this.initImgHelper(pid, whitelistBuffer) + + if (success) { + this.isHooked = true + this.currentPid = pid + console.log('[ImageDownloadService] hook successful') + return { success: true } + } else { + const err = this.getImgHelperError() + console.error(`[ImageDownloadService] hook failed: ${err}`) + if (isManualStart && this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + return { success: false, error: err || 'Hook 失败' } + } + } catch (e: any) { + console.error('[ImageDownloadService] InitImgHelper call crashed:', e) + if (isManualStart && this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + return { success: false, error: `调用异常: ${e.message || String(e)}` } + } + } + + private async unhook() { + if (this.isHooked && this.uninstallImgHelper) { + try { + this.uninstallImgHelper() + } catch (e) { + console.error('[ImageDownloadService] uninstall failed:', e) + } + } + this.isHooked = false + this.currentPid = null + } + + async getStatus() { + return { + isHooked: this.isHooked, + pid: this.currentPid, + supported: process.platform === 'win32' && process.arch === 'x64' + } + } +} + +export const imageDownloadService = ImageDownloadService.getInstance() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 0566571..5554a29 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -10,7 +10,7 @@ * 设计原则: * - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API * - 所有失败静默处理,不影响主流程 - * - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制 + * - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt */ import https from 'https' @@ -21,6 +21,7 @@ import { URL } from 'url' import { app, Notification } from 'electron' import { ConfigService } from './config' import { chatService, ChatSession, Message } from './chatService' +import { snsService } from './snsService' import { weiboService } from './social/weiboService' // ─── 常量 ──────────────────────────────────────────────────────────────────── @@ -52,6 +53,9 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiMaxTokens', 'aiInsightFilterMode', 'aiInsightFilterList', + 'aiInsightAllowMomentsContext', + 'aiInsightMomentsContextCount', + 'aiInsightMomentsBindings', 'aiInsightAllowSocialContext', 'aiInsightSocialContextCount', 'aiInsightWeiboCookie', @@ -445,7 +449,7 @@ class InsightService { try { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }] + const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }] insightDebugSection( 'INFO', 'AI 测试连接请求', @@ -823,26 +827,13 @@ ${topMentionText} } /** - * 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。 + * 记录成功推送的见解,用于设置页展示今日触发统计。 */ - private recordTrigger(sessionId: string): string[] { + private recordTrigger(sessionId: string): void { 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 formatWeiboTimestamp(raw: string): string { @@ -853,12 +844,66 @@ ${topMentionText} return new Date(parsed).toLocaleString('zh-CN') } + private formatMomentsTimestamp(raw: unknown): string { + const numeric = Number(raw) + if (!Number.isFinite(numeric) || numeric <= 0) { + return '' + } + const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000 + return new Date(ms).toLocaleString('zh-CN') + } + + private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string { + const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim() + if (contentDesc) return contentDesc + + const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim() + if (linkTitle) return `[链接] ${linkTitle}` + + return '' + } + + private async getMomentsContextSection(sessionId: string): Promise { + const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true + if (!allowMomentsContext) return '' + + const bindings = + (this.config.get('aiInsightMomentsBindings') as Record | undefined) || {} + const isEnabledForSession = bindings[sessionId]?.enabled === true + if (!isEnabledForSession) return '' + + const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5) + const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5)) + + try { + const result = await snsService.getTimeline(momentsCount, 0, [sessionId]) + const posts = result.success && Array.isArray(result.timeline) ? result.timeline : [] + if (posts.length === 0) return '' + + const lines = posts + .map((post) => { + const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown }) + if (!text) return '' + const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text + const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime) + return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}` + }) + .filter(Boolean) as string[] + + if (lines.length === 0) return '' + insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`) + return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}` + } catch (error) { + insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`) + return '' + } + } + private async getSocialContextSection(sessionId: string): Promise { const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true if (!allowSocialContext) return '' const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim() - const hasCookie = rawCookie.length > 0 const bindings = (this.config.get('aiInsightWeiboBindings') as Record | undefined) || {} @@ -879,10 +924,7 @@ ${topMentionText} return `[微博 ${time}] ${text}` }) insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`) - const riskHint = hasCookie - ? '' - : '\n提示:未配置微博 Cookie,使用移动端公开接口抓取,可能因平台风控导致获取失败或内容较少。' - return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}` + return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}` } catch (error) { insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`) return '' @@ -1118,10 +1160,6 @@ ${topMentionText} // ── 构建 prompt ──────────────────────────────────────────────────────────── - // 今日触发统计(让模型具备时间与克制感) - const sessionTriggerTimes = this.recordTrigger(sessionId) - const totalTodayTriggers = this.getTodayTotalTriggerCount() - let contextSection = '' if (allowContext) { try { @@ -1136,6 +1174,7 @@ ${topMentionText} } } + const momentsContextSection = await this.getMomentsContextSection(sessionId) const socialContextSection = await this.getSocialContextSection(sessionId) // ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)──── @@ -1151,25 +1190,12 @@ ${topMentionText} 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} 天没有和「${resolvedDisplayName}」聊天了。` - : `你最近和「${resolvedDisplayName}」有新的聊天动态。` - - const todayStatsDesc = - sessionTriggerTimes.length > 1 - ? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。` - : `今天你还没有针对「${resolvedDisplayName}」发出过见解。` - - const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。` - const userPromptBase = [ - `触发原因:${triggerDesc}`, - `时间统计:${todayStatsDesc}`, - `全局统计:${globalStatsDesc}`, + triggerReason === 'silence' && silentDays + ? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。` + : '', contextSection, + momentsContextSection, socialContextSection, '请给出你的见解(≤80字):' ].filter(Boolean).join('\n\n') @@ -1189,7 +1215,7 @@ ${topMentionText} `接口地址:${endpoint}`, `模型:${model}`, `Max Tokens:${maxTokens}`, - `触发原因:${triggerReason}`, + `触发类型:${triggerReason}`, `上下文开关:${allowContext ? '开启' : '关闭'}`, `上下文条数:${contextCount}`, '', @@ -1253,6 +1279,7 @@ ${topMentionText} } insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`) + this.recordTrigger(sessionId) } catch (e) { insightDebugSection( 'ERROR', diff --git a/electron/services/messageCacheService.ts b/electron/services/messageCacheService.ts index a340cbe..9d3079a 100644 --- a/electron/services/messageCacheService.ts +++ b/electron/services/messageCacheService.ts @@ -1,5 +1,6 @@ import { join, dirname } from 'path' import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' export interface SessionMessageCacheEntry { diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 87f7058..4785621 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,9 +1,9 @@ import { join } from 'path' import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { pathToFileURL } from 'url' +import { app } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' -import { getPathFallback } from './electronRuntime' export interface VideoInfo { videoUrl?: string // 视频文件路径(用于 readFile) @@ -45,7 +45,7 @@ class VideoService { try { const timestamp = new Date().toISOString() const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logDir = join(getPathFallback('userData'), 'logs') + const logDir = join(app.getPath('userData'), 'logs') if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') } catch { } diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 61fc8d6..5cc7804 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -1,9 +1,9 @@ +import { app } from 'electron' import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs' import { join } from 'path' import * as https from 'https' import * as http from 'http' import { ConfigService } from './config' -import { getPathFallback } from './electronRuntime' // Sherpa-onnx 类型定义 type OfflineRecognizer = any @@ -91,7 +91,7 @@ export class VoiceTranscribeService { private resolveModelDir(): string { const configured = this.configService.get('whisperModelDir') as string | undefined if (configured) return configured - return join(getPathFallback('documents'), 'WeFlow', 'models', 'sensevoice') + return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice') } private resolveModelPath(fileName: string): string { diff --git a/resources/image/README.md b/resources/image/README.md new file mode 100644 index 0000000..a964638 --- /dev/null +++ b/resources/image/README.md @@ -0,0 +1 @@ +> 目前只适配了x64 win32平台,其它平台同样原理,但是代码还没写( \ No newline at end of file diff --git a/resources/image/win32/x64/img_helper.dll b/resources/image/win32/x64/img_helper.dll new file mode 100644 index 0000000..bfc0859 Binary files /dev/null and b/resources/image/win32/x64/img_helper.dll differ diff --git a/resources/installer/linux/.gitignore b/resources/installer/linux/.gitignore new file mode 100644 index 0000000..32accc2 --- /dev/null +++ b/resources/installer/linux/.gitignore @@ -0,0 +1,6 @@ +*.tar.gz +*.tar.xz +*.zip +src/ +pkg/ +weflow-*/ diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index 3c29db5..56551b4 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index af13abb..48e2f17 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index 33f9cc1..8e61331 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index bf072ff..d3919d4 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 458c7e4..b575665 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -11,8 +11,7 @@ .export-date-range-dialog { width: min(480px, calc(100vw - 32px)); - max-height: calc(100vh - 64px); - overflow-y: auto; + max-height: calc(100vh - 80px); border-radius: 16px; border: 1px solid var(--border-color); background: var(--bg-secondary-solid, var(--bg-primary)); @@ -21,12 +20,14 @@ flex-direction: column; gap: 10px; box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); + overflow: hidden; } .export-date-range-dialog-header { display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; h4 { margin: 0; @@ -35,6 +36,26 @@ } } +.export-date-range-dialog-content { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 2px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } +} + .export-date-range-dialog-close-btn { border: 1px solid var(--border-color); background: var(--bg-secondary); @@ -439,6 +460,7 @@ display: flex; justify-content: flex-end; gap: 8px; + flex-shrink: 0; } .export-date-range-dialog-btn { diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index d2cbabf..c858472 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -565,6 +565,7 @@ export function ExportDateRangeDialog({ +
{EXPORT_DATE_RANGE_PRESETS.map((preset) => { const active = isPresetActive(preset.value) @@ -728,6 +729,7 @@ export function ExportDateRangeDialog({ })}
+
+ +
+
+ + + {autoDownloadHighRes ? '服务已开启' : '服务已关闭'} + +
+ + + +
+
+ 已选 {selectedCount} 个目标会话 + (若不选则默认对所有聊天生效) +
+
+ + +
+
+ 会话({filteredSessions.length}) + 状态 +
+ {filteredSessions.length === 0 ? ( +
{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}
+ ) : ( + filteredSessions.map((session) => { + const isSelected = autoDownloadSelectedIds.has(session.username) + return ( +
+ +
+ +