/** * 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, ChatSession, Message } 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[] } // ─── 工具函数 ───────────────────────────────────────────────────────────────── /** * 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。 * * 例如: * baseUrl = "https://api.ohmygpt.com/v1" * path = "/chat/completions" * 结果为 "https://api.ohmygpt.com/v1/chat/completions" * * 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)), * 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地 * 丢弃 v1,变成 https://api.example.com/chat/completions。 */ function buildApiUrl(baseUrl: string, path: string): string { const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠 const suffix = path.startsWith('/') ? path : `/${path}` return `${base}${suffix}` } function getStartOfDay(date: Date = new Date()): number { const d = new Date(date) d.setHours(0, 0, 0, 0) return d.getTime() } function formatTimestamp(ts: number): string { return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 */ function callApi( apiBaseUrl: string, apiKey: string, model: string, messages: Array<{ role: string; content: string }>, timeoutMs: number = API_TIMEOUT_MS ): Promise { return new Promise((resolve, reject) => { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') let urlObj: URL try { urlObj = new URL(endpoint) } catch (e) { reject(new Error(`无效的 API URL: ${endpoint}`)) return } const body = JSON.stringify({ model, messages, max_tokens: 200, temperature: 0.7, stream: false }) const options = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'POST' as const, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body).toString(), Authorization: `Bearer ${apiKey}` } } const isHttps = urlObj.protocol === 'https:' const requestFn = isHttps ? https.request : http.request const req = requestFn(options, (res) => { let data = '' res.on('data', (chunk) => { data += chunk }) res.on('end', () => { try { const parsed = JSON.parse(data) const content = parsed?.choices?.[0]?.message?.content if (typeof content === 'string' && content.trim()) { resolve(content.trim()) } else { reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) } } catch (e) { reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) } }) }) req.setTimeout(timeoutMs, () => { req.destroy() reject(new Error('API 请求超时')) }) req.on('error', (e) => reject(e)) req.write(body) req.end() }) } // ─── InsightService 主类 ────────────────────────────────────────────────────── class InsightService { private readonly config: ConfigService /** DB 变更防抖定时器 */ private dbDebounceTimer: NodeJS.Timeout | null = null /** 沉默扫描定时器 */ private silenceScanTimer: NodeJS.Timeout | null = null private silenceInitialDelayTimer: NodeJS.Timeout | null = null /** 是否正在处理中(防重入) */ private processing = false /** * 当日触发记录:sessionId -> TodayTriggerRecord * 每天 00:00 之后自动重置(通过检查日期实现) */ private todayTriggers: Map = new Map() private todayDate = getStartOfDay() 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 !== null) { clearTimeout(this.dbDebounceTimer) this.dbDebounceTimer = null } if (this.silenceScanTimer !== null) { clearInterval(this.silenceScanTimer) this.silenceScanTimer = null } if (this.silenceInitialDelayTimer !== null) { 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 !== null) { clearTimeout(this.dbDebounceTimer) } this.dbDebounceTimer = setTimeout(() => { this.dbDebounceTimer = null void this.analyzeRecentActivity() }, DB_CHANGE_DEBOUNCE_MS) } /** * 测试 API 连接,返回 { success, message }。 * 供设置页"测试连接"按钮调用。 */ async testConnection(): Promise<{ success: boolean; message: string }> { const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string const apiKey = this.config.get('aiInsightApiKey') as string const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini' if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 API Key' } } try { const result = await callApi( apiBaseUrl, apiKey, model, [{ role: 'user', content: '请回复"连接成功"四个字。' }], 15_000 ) return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` } } catch (e) { return { success: false, message: `连接失败:${(e as Error).message}` } } } /** 获取今日触发统计(供设置页展示) */ getTodayStats(): { sessionId: string; count: number; times: string[] }[] { this.resetIfNewDay() const result: { sessionId: string; count: number; times: string[] }[] = [] for (const [sessionId, record] of this.todayTriggers.entries()) { result.push({ sessionId, count: record.timestamps.length, times: record.timestamps.map(formatTimestamp) }) } return result } // ── 私有方法 ──────────────────────────────────────────────────────────────── private isEnabled(): boolean { return this.config.get('aiInsightEnabled') === true } /** * 判断某个会话是否允许触发见解。 * 若白名单未启用,则所有私聊会话均允许; * 若白名单已启用,则只有在白名单中的会话才允许。 */ private isSessionAllowed(sessionId: string): boolean { const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean if (!whitelistEnabled) return true const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || [] return whitelist.includes(sessionId) } 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: ChatSession[] = sessionsResult.sessions for (const session of sessions) { const sessionId = session.username?.trim() || '' if (!sessionId || sessionId.endsWith('@chatroom')) continue // 跳过群聊 if (sessionId.toLowerCase().includes('placeholder')) continue if (!this.isSessionAllowed(sessionId)) continue // 白名单过滤 // lastTimestamp 单位是秒,需要转换为毫秒 const lastTimestamp = (session.lastTimestamp || 0) * 1000 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: ChatSession[] = sessionsResult.sessions // 只取最近有活动的前 5 个会话(排除群聊以降低噪音,可按需调整) const candidates = sessions .filter((s) => { const id = s.username?.trim() || '' return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id) }) .slice(0, 5) for (const session of candidates) { await this.generateInsightForSession({ sessionId: 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 messages: Message[] = msgsResult.messages const msgLines = messages.map((m) => { const sender = m.isSend === 1 ? '我' : (displayName || sessionId) const content = m.rawContent || m.parsedContent || '[非文字消息]' const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN') return `[${time}] ${sender}:${content}` }) contextSection = `\n\n��期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}` } } 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()