From 93a9df48f4ad6f90d3b67b65ce121d2deed26aeb Mon Sep 17 00:00:00 2001 From: v0 Date: Sun, 5 Apr 2026 15:32:22 +0000 Subject: [PATCH] feat: implement AI insights service and settings tab Add core insight service and IPC handlers; update config and settings page. Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com> --- electron/main.ts | 13 + electron/preload.ts | 6 + electron/services/config.ts | 18 +- electron/services/insightService.ts | 491 ++++++++++++++++++++++++++++ src/pages/SettingsPage.tsx | 249 +++++++++++++- src/services/config.ts | 66 +++- 6 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 electron/services/insightService.ts diff --git a/electron/main.ts b/electron/main.ts index ce578dc..3aa813c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,6 +30,7 @@ import { cloudControlService } from './services/cloudControlService' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' +import { insightService } from './services/insightService' import { bizService } from './services/bizService' // 配置自动更新 @@ -1394,6 +1395,15 @@ function registerIpcHandlers() { return result }) + // AI 见解 + ipcMain.handle('insight:testConnection', async () => { + return insightService.testConnection() + }) + + ipcMain.handle('insight:getTodayStats', async () => { + return insightService.getTodayStats() + }) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) @@ -3119,8 +3129,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) // 检查配置状态 @@ -3241,6 +3253,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 88385ce..79c7d0f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -442,5 +442,11 @@ 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') } }) diff --git a/electron/services/config.ts b/electron/services/config.ts index d65d93b..8b4fad1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -62,10 +62,18 @@ 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 } // 需要 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']) @@ -135,7 +143,13 @@ 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 } const storeOptions: any = { diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts new file mode 100644 index 0000000..4376327 --- /dev/null +++ b/electron/services/insightService.ts @@ -0,0 +1,491 @@ +/** + * 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 { ConfigService } from './config' +import { chatService } from './chatService' +import { showNotification } from '../windows/notificationWindow' + +// ─── 常量 ──────────────────────────────────────────────────────────────────── + +/** DB 变更防抖延迟(毫秒) */ +const DB_CHANGE_DEBOUNCE_MS = 500 + +/** 沉默扫描间隔(毫秒),4 小时 */ +const SILENCE_SCAN_INTERVAL_MS = 4 * 60 * 60 * 1000 + +/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */ +const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000 + +/** 单次 API 请求超时(毫秒) */ +const API_TIMEOUT_MS = 45_000 + +/** 最大上下文消息数(授权发送真实上下文时) */ +const MAX_CONTEXT_MESSAGES = 40 + +/** 沉默天数阈值默认值 */ +const DEFAULT_SILENCE_DAYS = 3 + +// ─── 类型 ──────────────────────────────────────────────────────────────────── + +interface TodayTriggerRecord { + /** 该会话今日触发的时间戳列表(毫秒) */ + timestamps: number[] +} + +interface InsightResult { + sessionId: string + displayName: string + insight: string +} + +// ─── 工具函数 ───────────────────────────────────────────────────────────────── + +/** + * 绝对拼接 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: https.RequestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + Authorization: `Bearer ${apiKey}` + } + } + + const transport = urlObj.protocol === 'https:' ? https : http + const req = (transport as typeof https).request(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() + + private started = false + + constructor() { + this.config = ConfigService.getInstance() + } + + // ── 公开 API ──────────────────────────────────────────────────────────────── + + start(): void { + if (this.started) return + this.started = true + console.log('[InsightService] 已启动') + this.scheduleSilenceScan() + } + + stop(): void { + this.started = false + if (this.dbDebounceTimer) { clearTimeout(this.dbDebounceTimer); this.dbDebounceTimer = null } + if (this.silenceScanTimer) { clearInterval(this.silenceScanTimer); this.silenceScanTimer = null } + if (this.silenceInitialDelayTimer) { clearTimeout(this.silenceInitialDelayTimer); this.silenceInitialDelayTimer = null } + console.log('[InsightService] 已停止') + } + + /** + * 由 main.ts 在 addDbMonitorListener 回调中调用。 + * 加入 500ms 防抖,防止开机/重连时大量事件并发阻塞主线程。 + */ + handleDbMonitorChange(_type: string, _json: string): void { + if (!this.started) return + if (!this.isEnabled()) return + + if (this.dbDebounceTimer) 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}` } + } + } + + /** 获取今日触发统计(供设置页展示) */ + 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 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 { + // 延迟 3 分钟后首次扫描,之后每 4 小时一次 + this.silenceInitialDelayTimer = setTimeout(() => { + void this.runSilenceScan() + this.silenceScanTimer = setInterval(() => { + void this.runSilenceScan() + }, SILENCE_SCAN_INTERVAL_MS) + }, SILENCE_SCAN_INITIAL_DELAY_MS) + } + + private async runSilenceScan(): Promise { + if (!this.isEnabled()) return + if (this.processing) return + + this.processing = true + try { + const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS + const thresholdMs = silenceDays * 24 * 60 * 60 * 1000 + const now = Date.now() + + const connectResult = await chatService.connect() + if (!connectResult.success) return + + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) return + + const sessions = sessionsResult.sessions as any[] + + for (const session of sessions) { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.endsWith('@chatroom')) continue // 跳过群聊 + if (sessionId.toLowerCase().includes('placeholder')) continue + + const lastTimestamp = Number(session.lastTimestamp || 0) * (String(session.lastTimestamp).length <= 10 ? 1000 : 1) + if (!lastTimestamp || lastTimestamp <= 0) continue + + const silentMs = now - lastTimestamp + if (silentMs < thresholdMs) continue + + // 沉默时间满足阈值,触发见解 + await this.generateInsightForSession({ + sessionId, + displayName: session.displayName || session.username, + triggerReason: 'silence', + silentDays: Math.floor(silentMs / (24 * 60 * 60 * 1000)) + }) + } + } catch (e) { + console.warn('[InsightService] 沉默扫描出错:', e) + } finally { + this.processing = false + } + } + + // ── 活跃会话分析 ──────────────────────────────────────────────────────────── + + /** + * 在 DB 变更防抖后执行,分析最近活跃的会话, + * 判断是否有有趣的上下文值得产出见解。 + */ + private async analyzeRecentActivity(): Promise { + if (!this.isEnabled()) return + if (this.processing) return + + this.processing = true + try { + const connectResult = await chatService.connect() + if (!connectResult.success) return + + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) return + + const sessions = sessionsResult.sessions as any[] + // 只取最近有活动的前 5 个会话(排除群聊以降低噪音,可按需调整) + const candidates = sessions + .filter((s) => { + const id = String(s.username || '').trim() + return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') + }) + .slice(0, 5) + + for (const session of candidates) { + await this.generateInsightForSession({ + sessionId: String(session.username || '').trim(), + displayName: session.displayName || session.username, + triggerReason: 'activity' + }) + } + } catch (e) { + console.warn('[InsightService] 活跃分析出错:', e) + } 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 + + 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 + + if (!apiBaseUrl || !apiKey) return + + // ── 构建 prompt ────────────────────────────────────────────────────────── + + // 今日触发统计(让模型具备时间与克制感) + const sessionTriggerTimes = this.recordTrigger(sessionId) + const totalTodayTriggers = this.getTodayTotalTriggerCount() + + let contextSection = '' + if (allowContext) { + try { + const msgsResult = await chatService.getLatestMessages(sessionId, MAX_CONTEXT_MESSAGES) + if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) { + const msgLines = (msgsResult.messages as any[]).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')}` + } + } catch (e) { + console.warn('[InsightService] 拉取上下文失败:', e) + } + } + + 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 systemPrompt = `你是用户的私人关系观察助手,名叫"见解"。你的任务是: +1. 根据提供的聊天上下文(如有),给出真正有洞察力、有价值的简短见解或建议。 +2. 严格控制输出在 80 字以内,直接、具体、一针见血。不要废话,不要寒暄。 +3. ${todayStatsDesc} ${globalStatsDesc} 如果今日触发已经较多,请考虑是否真的有必要此时发出。若没有新意,可以直接回复"SKIP"表示跳过。 +4. 若提供了聊天记录,请基于真实内容分析,而非泛泛而谈。 +5. 输出纯文本,不使用 Markdown 格式符号。` + + const userPrompt = `触发原因:${triggerDesc}${contextSection} + +请给出你的见解(≤80字)或回复 SKIP 跳过:` + + try { + const result = await callApi( + apiBaseUrl, + apiKey, + model, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + ) + + // 模型主动选择跳过 + if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) { + console.log(`[InsightService] 模型选择跳过 ${sessionId}`) + return + } + + const insight = result.slice(0, 120) // 防止超长截断 + + // 通过现有 showNotification 推送弹窗 + await showNotification({ + sessionId, + sourceName: `见解 · ${displayName}`, + content: insight, + isInsight: true // 可用于通知窗口做差异化展示 + }) + + console.log(`[InsightService] 已为 ${sessionId} 推送见解`) + } catch (e) { + console.warn(`[InsightService] API 调用失败 (${sessionId}):`, (e as Error).message) + } + } +} + +export const insightService = new InsightService() diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 316228c..a3c0aed 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 } @@ -213,6 +215,17 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache + // AI 见解 state + const [aiInsightEnabled, setAiInsightEnabled] = useState(false) + const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('') + const [aiInsightApiKey, setAiInsightApiKey] = useState('') + const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini') + const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) + const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) + const [isTestingInsight, setIsTestingInsight] = useState(false) + const [insightTestResult, setInsightTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [showInsightApiKey, setShowInsightApiKey] = useState(false) + const [isWayland, setIsWayland] = useState(false) useEffect(() => { const checkWaylandStatus = async () => { @@ -438,6 +451,19 @@ 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() + setAiInsightEnabled(savedAiInsightEnabled) + setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl) + setAiInsightApiKey(savedAiInsightApiKey) + setAiInsightApiModel(savedAiInsightApiModel) + setAiInsightSilenceDays(savedAiInsightSilenceDays) + setAiInsightAllowContext(savedAiInsightAllowContext) } catch (e: any) { console.error('加载配置失败:', e) @@ -579,7 +605,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) await handleCheckUpdate() } catch (e: any) { - showMessage(`切换更新渠道失败: ${e}`, false) + showMessage(`切换更新渠道��败: ${e}`, false) } } @@ -2451,6 +2477,222 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) } + const handleTestInsightConnection = async () => { + setIsTestingInsight(true) + setInsightTestResult(null) + try { + const result = await (window.electronAPI as any).insight.testConnection() + setInsightTestResult(result) + } catch (e: any) { + setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` }) + } finally { + setIsTestingInsight(false) + } + } + + const renderInsightTab = () => ( +
+ {/* 总开关 */} +
+ + + 开启后,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' }} + /> +
+ + {/* 测试连接 */} +
+
+ + {insightTestResult && ( + + {insightTestResult.success ? : } + {insightTestResult.message} + + )} +
+
+ +
+ + {/* 行为配置 */} +
+ + + 当你与某个私聊联系人超过此天数没有发过消息时,AI 会主动扫描并尝试给出见解。每 4 小时扫描一次。 + + { + const val = Math.max(1, parseInt(e.target.value, 10) || 3) + setAiInsightSilenceDays(val) + scheduleConfigSave('aiInsightSilenceDays', () => configService.setAiInsightSilenceDays(val)) + }} + style={{ width: 100 }} + /> +
+ +
+ + + 开启后,AI 见解触发时会将该联系人最近 40 条真实聊天记录一并发送给 AI,使其能够基于真实内容给出有意义的分析,而非泛泛而谈。 +
+ 关闭时:AI 仅知道"与某人沉默了 N 天"等统计摘要,输出质量会显著降低。 +
+ 开启时:聊天文本内容(不含图片、语音)会通过你配置的 API 发送给你选择的模型提供商。请确认你信任该服务商。 +
+
+ {aiInsightAllowContext ? '已授权' : '未授权'} + +
+
+ +
+ + {/* 工作原理说明 */} +
+ +
+
+

+ 触发方式一:活跃会话分析 — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对最近活跃的私聊会话进行分析。
+ 触发方式二:沉默扫描 — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。
+ 时间观念 — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。
+ 隐私 — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。 +

+
+
+
+
+ ) + const renderApiTab = () => (
@@ -3135,6 +3377,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {activeTab === 'models' && renderModelsTab()} {activeTab === 'cache' && renderCacheTab()} {activeTab === 'api' && renderApiTab()} + {activeTab === 'insight' && renderInsightTab()} {activeTab === 'updates' && renderUpdatesTab()} {activeTab === 'analytics' && renderAnalyticsTab()} {activeTab === 'security' && renderSecurityTab()} diff --git a/src/services/config.ts b/src/services/config.ts index a0b7c54..5f49c53 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -79,7 +79,15 @@ export const CONFIG_KEYS = { // 数据收集 ANALYTICS_CONSENT: 'analyticsConsent', - ANALYTICS_DENY_COUNT: 'analyticsDenyCount' + ANALYTICS_DENY_COUNT: 'analyticsDenyCount', + + // AI 见解 + AI_INSIGHT_ENABLED: 'aiInsightEnabled', + AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl', + AI_INSIGHT_API_KEY: 'aiInsightApiKey', + AI_INSIGHT_API_MODEL: 'aiInsightApiModel', + AI_INSIGHT_SILENCE_DAYS: 'aiInsightSilenceDays', + AI_INSIGHT_ALLOW_CONTEXT: 'aiInsightAllowContext' } as const export interface WxidConfig { @@ -1551,3 +1559,59 @@ export async function getHttpApiHost(): Promise { export async function setHttpApiHost(host: string): Promise { await config.set(CONFIG_KEYS.HTTP_API_HOST, host) } + +// ─── AI 见解 ────────────────────────────────────────────────────────────────── + +export async function getAiInsightEnabled(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED) + return value === true +} + +export async function setAiInsightEnabled(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_ENABLED, enabled) +} + +export async function getAiInsightApiBaseUrl(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL) + return typeof value === 'string' ? value : '' +} + +export async function setAiInsightApiBaseUrl(url: string): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL, url) +} + +export async function getAiInsightApiKey(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY) + return typeof value === 'string' ? value : '' +} + +export async function setAiInsightApiKey(key: string): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_API_KEY, key) +} + +export async function getAiInsightApiModel(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL) + return typeof value === 'string' && value.trim() ? value.trim() : 'gpt-4o-mini' +} + +export async function setAiInsightApiModel(model: string): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_API_MODEL, model) +} + +export async function getAiInsightSilenceDays(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_SILENCE_DAYS) + return typeof value === 'number' && value > 0 ? value : 3 +} + +export async function setAiInsightSilenceDays(days: number): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_SILENCE_DAYS, days) +} + +export async function getAiInsightAllowContext(): Promise { + const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT) + return value === true +} + +export async function setAiInsightAllowContext(allow: boolean): Promise { + await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow) +}