Files
WeFlow/electron/services/insightService.ts
v0 93a9df48f4 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>
2026-04-05 15:32:22 +00:00

492 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string> {
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<string, TodayTriggerRecord> = 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<void> {
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<void> {
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<void> {
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()