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/config.ts b/electron/services/config.ts index ff06ccd..2973e2d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -85,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 @@ -110,6 +116,8 @@ interface ConfigSchema { aiFootprintSystemPrompt: string /** 是否将 AI 见解调试日志输出到桌面 */ aiInsightDebugLogEnabled: boolean + autoDownloadHighRes: boolean + autoDownloadWhitelist: string[] } // 需要 safeStorage 加密的字段(普通模式) @@ -205,6 +213,9 @@ export class ConfigService { aiInsightApiModel: 'gpt-4o-mini', aiInsightSilenceDays: 3, aiInsightAllowContext: false, + aiInsightAllowMomentsContext: false, + aiInsightMomentsContextCount: 5, + aiInsightMomentsBindings: {}, aiInsightAllowSocialContext: false, aiInsightFilterMode: 'whitelist', aiInsightFilterList: [], @@ -222,7 +233,9 @@ export class ConfigService { aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', - aiInsightDebugLogEnabled: false + aiInsightDebugLogEnabled: false, + autoDownloadHighRes: false, + autoDownloadWhitelist: [] } const storeOptions: any = { 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/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/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 76188e1..ecf1d8d 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -915,6 +915,31 @@ color: var(--text-secondary); } +.insight-collapsible-setting { + max-height: 0; + opacity: 0; + overflow: hidden; + transform: translate3d(0, -4px, 0); + contain: layout paint; + will-change: max-height, opacity, transform; + transition: max-height 0.2s ease, opacity 0.18s ease, transform 0.2s ease; + + &.expanded { + max-height: 128px; + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &.collapsed { + pointer-events: none; + } +} + +.insight-collapsible-setting-inner { + padding-top: 2px; + backface-visibility: hidden; +} + /* Premium Switch Style */ .switch { position: relative; @@ -3616,17 +3641,35 @@ } &.insight-social-tab { + --insight-moments-column-width: 76px; + --insight-social-column-width: minmax(220px, 300px); + --insight-status-column-width: 82px; + --insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width); + .anti-revoke-list-header { - grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; + grid-template-columns: var(--insight-social-list-grid); + gap: 14px; + + .insight-moments-column-title { + display: flex; + justify-content: center; + color: var(--text-tertiary); + } .insight-social-column-title { + min-width: 0; + color: var(--text-tertiary); + } + + .anti-revoke-status-column-title { + justify-self: end; color: var(--text-tertiary); } } .anti-revoke-row { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(300px, 420px) auto; + grid-template-columns: var(--insight-social-list-grid); align-items: center; gap: 14px; } @@ -3635,6 +3678,67 @@ min-width: 0; } + .insight-moments-cell { + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + min-height: 30px; + } + + .insight-moments-toggle { + position: relative; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + input[type='checkbox'] { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; + } + + .check-indicator { + width: 100%; + height: 100%; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%); + color: var(--on-primary, #fff); + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.16s ease; + + svg { + opacity: 0; + transform: scale(0.75); + transition: opacity 0.16s ease, transform 0.16s ease; + } + } + + input[type='checkbox']:checked + .check-indicator { + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + + svg { + opacity: 1; + transform: scale(1); + } + } + + input[type='checkbox']:focus-visible + .check-indicator { + outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent); + outline-offset: 1px; + } + } + .insight-social-binding-cell { min-width: 0; display: grid; @@ -3653,7 +3757,7 @@ .binding-platform-chip { flex-shrink: 0; border-radius: 999px; - padding: 2px 8px; + padding: 2px 7px; font-size: 11px; color: var(--text-secondary); border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); @@ -3663,7 +3767,7 @@ .insight-social-binding-input { width: 100%; min-width: 0; - height: 30px; + height: 28px; border-radius: 8px; border: 1px solid var(--border-color); background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%); @@ -3706,9 +3810,10 @@ } .anti-revoke-row-status { - justify-self: flex-end; + justify-self: end; align-items: flex-end; max-width: none; + min-width: 0; } } @@ -3752,6 +3857,7 @@ .anti-revoke-list-header { grid-template-columns: minmax(0, 1fr) auto; + .insight-moments-column-title, .insight-social-column-title { display: none; } @@ -3763,11 +3869,16 @@ flex-direction: column; } + .insight-moments-cell, .insight-social-binding-cell, .anti-revoke-row-status { width: 100%; } + .insight-moments-cell { + justify-content: flex-start; + } + .insight-social-binding-cell { grid-template-columns: 1fr; } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 9025a73..fb53979 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -32,6 +32,7 @@ type SettingsTab = | 'aiCommon' | 'insight' | 'aiFootprint' + | 'autoDownload' const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, @@ -39,6 +40,7 @@ const tabs: { id: Exclude; label: string { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'models', label: '模型管理', icon: Mic }, + { id: 'autoDownload', label: '自动下载', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, { id: 'analytics', label: '分析', icon: BarChart2 }, @@ -47,6 +49,13 @@ const tabs: { id: Exclude; label: string { id: 'about', label: '关于', icon: Info } ] +const filteredTabs = tabs.filter(tab => { + if (tab.id === 'autoDownload') { + return (window as any).electronAPI.process.platform === 'win32' && (window as any).electronAPI.process.arch === 'x64' + } + return true +}) + const aiTabs: Array<{ id: Extract; label: string }> = [ { id: 'aiCommon', label: '基础配置' }, { id: 'insight', label: 'AI 见解' }, @@ -149,6 +158,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [imageKeyPercent, setImageKeyPercent] = useState(null) const [logEnabled, setLogEnabled] = useState(false) + const [autoDownloadHighRes, setAutoDownloadHighRes] = useState(false) const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelDir, setWhisperModelDir] = useState('') const [isWhisperDownloading, setIsWhisperDownloading] = useState(false) @@ -284,6 +294,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiModelApiMaxTokens, setAiModelApiMaxTokens] = useState(200) const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) + const [aiInsightAllowMomentsContext, setAiInsightAllowMomentsContext] = useState(false) + const [aiInsightMomentsContextCount, setAiInsightMomentsContextCount] = useState(5) + const [aiInsightMomentsBindings, setAiInsightMomentsBindings] = useState>({}) const [isTestingInsight, setIsTestingInsight] = useState(false) const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null) const [showInsightApiKey, setShowInsightApiKey] = useState(false) @@ -315,6 +328,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false) + // 自动下载图片 + const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null) + const [autoDownloadSelectedIds, setAutoDownloadSelectedIds] = useState>(new Set()) + const [autoDownloadSearchKeyword, setAutoDownloadSearchKeyword] = useState('') + // 检查 Hello 可用性 useEffect(() => { setHelloAvailable(isWindows) @@ -526,9 +544,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setWordCloudExcludeWords(savedExcludeWords) setExcludeWordsInput(savedExcludeWords.join('\n')) + const savedAutoDownloadHighRes = await configService.getAutoDownloadHighRes() + const savedAutoDownloadWhitelist = await configService.getAutoDownloadWhitelist() const savedAnalyticsConsent = await configService.getAnalyticsConsent() setAnalyticsConsent(savedAnalyticsConsent ?? false) - + setAutoDownloadHighRes(savedAutoDownloadHighRes) + setAutoDownloadSelectedIds(new Set(savedAutoDownloadWhitelist)) // 如果语言列表为空,保存默认值 @@ -549,6 +570,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiModelApiMaxTokens = await configService.getAiModelApiMaxTokens() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() + const savedAiInsightAllowMomentsContext = await configService.getAiInsightAllowMomentsContext() + const savedAiInsightMomentsContextCount = await configService.getAiInsightMomentsContextCount() + const savedAiInsightMomentsBindings = await configService.getAiInsightMomentsBindings() const savedAiInsightFilterMode = await configService.getAiInsightFilterMode() const savedAiInsightFilterList = await configService.getAiInsightFilterList() const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() @@ -573,6 +597,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiModelApiMaxTokens(savedAiModelApiMaxTokens) setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightAllowContext(savedAiInsightAllowContext) + setAiInsightAllowMomentsContext(savedAiInsightAllowMomentsContext) + setAiInsightMomentsContextCount(savedAiInsightMomentsContextCount) + setAiInsightMomentsBindings(savedAiInsightMomentsBindings) setAiInsightFilterMode(savedAiInsightFilterMode) setAiInsightFilterList(new Set(savedAiInsightFilterList)) setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) @@ -685,6 +712,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { void refreshWhisperStatus(whisperModelDir) }, [whisperModelDir]) + useEffect(() => { + if (activeTab === 'autoDownload') { + fetchAutoDownloadStatus() + + let interval: ReturnType | undefined + if (autoDownloadHighRes) { + interval = setInterval(fetchAutoDownloadStatus, 2000) + } + + return () => { + if (interval) clearInterval(interval) + } + } + }, [activeTab, autoDownloadHighRes]) + const getErrorMessage = (error: any): string => { const raw = typeof error?.message === 'string' ? error.message : String(error ?? '') const normalized = raw.replace(/^Error:\s*/i, '').trim() @@ -1013,11 +1055,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } useEffect(() => { - if (activeTab !== 'antiRevoke' && activeTab !== 'insight') return + if (activeTab !== 'antiRevoke' && activeTab !== 'insight' && activeTab !== 'autoDownload') return let canceled = false ;(async () => { try { - if (activeTab === 'antiRevoke') { + if (activeTab === 'antiRevoke' || activeTab === 'autoDownload') { await ensureAntiRevokeSessionsLoaded() } else { await ensureChatSessionsLoaded() @@ -1579,6 +1621,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const fetchAutoDownloadStatus = async () => { + try { + const status = await (window as any).electronAPI.image.getAutoDownloadStatus() + setAutoDownloadStatus(status) + } catch (error) { + console.error('获取自动下载状态失败:', error) + } + } + const renderAppearanceTab = () => (
@@ -3081,6 +3132,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { }) } + const isMomentsEnabledForSession = (sessionId: string): boolean => { + return aiInsightMomentsBindings[sessionId]?.enabled === true + } + + const handleToggleMomentsBinding = async (sessionId: string, enabled: boolean) => { + const nextBindings = { ...aiInsightMomentsBindings } + if (enabled) { + nextBindings[sessionId] = { + enabled: true, + updatedAt: Date.now() + } + } else { + delete nextBindings[sessionId] + } + setAiInsightMomentsBindings(nextBindings) + await configService.setAiInsightMomentsBindings(nextBindings) + } + const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => { const draftUid = getWeiboBindingDraftValue(sessionId) setWeiboBindingLoadingSessionId(sessionId) @@ -3274,7 +3343,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 开启后,触发见解时会将该联系人最近 N 条聊天记录发送给 AI,分析质量显著提升。
- 关闭时:AI 仅知道统计摘要(沉默天数等),输出质量较低。 + 关闭时:不会发送聊天原文,输出质量较低。
开启时:聊天文本内容(不含图片、语音)会通过你配置的 API 发送给模型提供商。请确认你信任该服务商。
@@ -3295,27 +3364,79 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {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 }} - /> +
+
+
+ + + 发送给 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 }} + /> +
- )} +
+ +
+ +
+ + + 开启后,可在下方列表为私聊联系人单独允许朋友圈补充分析。程序只会在触发见解时按需读取,不会做后台持续扫描。 + +
+ {aiInsightAllowMomentsContext ? '已开启' : '已关闭'} + +
+
+ +
+
+
+ + + 发送给 AI 的朋友圈最大条数。条数越多上下文越充分,token 消耗也越多。 + + { + const val = Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 5)) + setAiInsightMomentsContextCount(val) + scheduleConfigSave('aiInsightMomentsContextCount', () => configService.setAiInsightMomentsContextCount(val)) + }} + style={{ width: 100 }} + /> +
+
+
@@ -3354,29 +3475,32 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { )}
- {aiInsightAllowSocialContext && ( -
- - - 当前仅支持微博最近发帖。 -
- 不建议超过 5,避免触发平台风控。 -
- { - const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3)) - setAiInsightSocialContextCount(val) - scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val)) - }} - style={{ width: 100 }} - /> +
+
+
+ + + 当前仅支持微博最近发帖。 +
+ 不建议超过 5,避免触发平台风控。 +
+ { + const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3)) + setAiInsightSocialContextCount(val) + scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val)) + }} + style={{ width: 100 }} + /> +
- )} +
{/* 自定义 System Prompt */} @@ -3652,11 +3776,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { <>
对话({filteredSessions.length}) + 朋友圈 社交平台(微博) - 状态 + 状态
{filteredSessions.map((session) => { const isSelected = aiInsightFilterList.has(session.username) + const isPrivateSession = session.type === 'private' + const isMomentsEnabled = isMomentsEnabledForSession(session.username) const weiboBinding = aiInsightWeiboBindings[session.username] const weiboDraftValue = getWeiboBindingDraftValue(session.username) const isBindingLoading = weiboBindingLoadingSessionId === session.username @@ -3695,8 +3822,24 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {getSessionFilterTypeLabel(session.type)}
+
+ {isPrivateSession ? ( + + ) : ( + - + )} +
- {session.type === 'private' ? ( + {isPrivateSession ? ( <>
微博 @@ -3771,9 +3914,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

- 触发方式一:活跃会话分析 — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对符合黑白名单规则的活跃会话进行分析。
+ 触发方式一:活跃会话分析 — 每当微信数据库变化(即你收到新消息)时,经过约 2 秒防抖后,对符合黑白名单规则的活跃会话进行分析。
触发方式二:沉默扫描 — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。
- 时间观念 — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。
+ 频率控制 — 冷却期、沉默间隔、黑白名单均在本地判断,不额外发送给模型。
隐私 — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。

@@ -4557,6 +4700,203 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) + const renderAutoDownloadTab = () => { + const sortedSessions = [...antiRevokeSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const keyword = autoDownloadSearchKeyword.trim().toLowerCase() + const filteredSessions = sortedSessions.filter((session) => { + if (!keyword) return true + const displayName = String(session.displayName || '').toLowerCase() + const username = String(session.username || '').toLowerCase() + return displayName.includes(keyword) || username.includes(keyword) + }) + const filteredSessionIds = filteredSessions.map((session) => session.username) + const selectedCount = autoDownloadSelectedIds.size + const selectedInFilteredCount = filteredSessionIds.filter((id) => autoDownloadSelectedIds.has(id)).length + const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length + const isHooked = autoDownloadStatus?.isHooked + + const persistWhitelist = (ids: Set) => { + const whitelistArr = Array.from(ids) + configService.setAutoDownloadWhitelist(whitelistArr) + if (autoDownloadHighRes) { + const whitelistStr = whitelistArr.length > 0 ? (whitelistArr.join('\0') + '\0\0') : ''; + (window as any).electronAPI.image.startAutoDownload(whitelistStr) + } + } + + const toggleSelection = (id: string) => { + const next = new Set(autoDownloadSelectedIds) + if (next.has(id)) next.delete(id) + else next.add(id) + setAutoDownloadSelectedIds(next) + persistWhitelist(next) + } + + const selectAllFiltered = () => { + const next = new Set(autoDownloadSelectedIds) + filteredSessionIds.forEach(id => next.add(id)) + setAutoDownloadSelectedIds(next) + persistWhitelist(next) + } + + const clearSelection = () => { + const next = new Set() + setAutoDownloadSelectedIds(next) + persistWhitelist(next) + } + + return ( +
+ {/* 顶部 Hero 区域保持不变 */} +
+
+ 测试功能 (Test) +

自动下载原图

+

强制微信在接收图片时下载高清原图。建议仅在必要会话中开启以节省流量和空间。

+
+
+
+ 服务状态 + + {isHooked ? '正在监控' : autoDownloadHighRes ? '等待连接' : '未启用'} + +
+
+ 已选会话 + {selectedCount} +
+
+
+ +
+
+
+ + setAutoDownloadSearchKeyword(e.target.value)} + /> +
+
+
+ + +
+
+ + + {autoDownloadHighRes ? '服务已开启' : '服务已关闭'} + +
+
+
+ +
+
+ 已选 {selectedCount} 个目标会话 + (若不选则默认对所有聊天生效) +
+
+
+ +
+
+ 会话({filteredSessions.length}) + 状态 +
+ {filteredSessions.length === 0 ? ( +
{autoDownloadSearchKeyword ? '没有匹配的会话' : '暂无会话'}
+ ) : ( + filteredSessions.map((session) => { + const isSelected = autoDownloadSelectedIds.has(session.username) + return ( +
+ +
+ +