mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 07:25:51 +00:00
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>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = 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 = {
|
||||
|
||||
491
electron/services/insightService.ts
Normal file
491
electron/services/insightService.ts
Normal file
@@ -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<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()
|
||||
@@ -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(`切换更新渠道<EFBFBD><EFBFBD>败: ${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 = () => (
|
||||
<div className="tab-content">
|
||||
{/* 总开关 */}
|
||||
<div className="form-group">
|
||||
<label>AI 见解</label>
|
||||
<span className="form-hint">
|
||||
开启后,AI 会在后台默默分析聊天数据,在合适的时机通过右下角弹窗送出一针见血的见解——例如提醒你久未联系的朋友,或对你刚刚的对话提出回复建议。默认关闭,所有分析均在本地发起请求,不经过任何第三方中间服务。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightEnabled}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightEnabled(val)
|
||||
await configService.setAiInsightEnabled(val)
|
||||
showMessage(val ? 'AI 见解已开启' : 'AI 见解已关闭', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* API 配置 */}
|
||||
<div className="form-group">
|
||||
<label>API 地址</label>
|
||||
<span className="form-hint">
|
||||
填写 OpenAI 兼容接口的 <strong>Base URL</strong>,末尾<strong>不要加斜杠</strong>。
|
||||
程序会自动拼接 <code>/chat/completions</code>。
|
||||
<br />
|
||||
示例:<code>https://api.ohmygpt.com/v1</code> 或 <code>https://api.openai.com/v1</code>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={aiInsightApiBaseUrl}
|
||||
placeholder="https://api.ohmygpt.com/v1"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiInsightApiBaseUrl(val)
|
||||
scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val))
|
||||
}}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>API Key</label>
|
||||
<span className="form-hint">
|
||||
你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<input
|
||||
type={showInsightApiKey ? 'text' : 'password'}
|
||||
className="field-input"
|
||||
value={aiInsightApiKey}
|
||||
placeholder="sk-..."
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setAiInsightApiKey(val)
|
||||
scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val))
|
||||
}}
|
||||
style={{ flex: 1, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowInsightApiKey(!showInsightApiKey)}
|
||||
title={showInsightApiKey ? '隐藏' : '显示'}
|
||||
>
|
||||
{showInsightApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
{aiInsightApiKey && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={async () => {
|
||||
setAiInsightApiKey('')
|
||||
await configService.setAiInsightApiKey('')
|
||||
}}
|
||||
title="清除 Key"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>模型名称</label>
|
||||
<span className="form-hint">
|
||||
填写你的 API 提供商支持的模型名,建议使用综合能力较强的模型以获得有洞察力的见解。
|
||||
<br />
|
||||
常用示例:<code>gpt-4o-mini</code>、<code>gpt-4o</code>、<code>deepseek-chat</code>、<code>claude-3-5-haiku-20241022</code>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={aiInsightApiModel}
|
||||
placeholder="gpt-4o-mini"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.trim() || 'gpt-4o-mini'
|
||||
setAiInsightApiModel(val)
|
||||
scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val))
|
||||
}}
|
||||
style={{ width: 260, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 测试连接 */}
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestInsightConnection}
|
||||
disabled={isTestingInsight || !aiInsightApiBaseUrl || !aiInsightApiKey}
|
||||
>
|
||||
{isTestingInsight ? (
|
||||
<><Loader2 size={14} style={{ marginRight: 4, animation: 'spin 1s linear infinite' }} /> 测试中...</>
|
||||
) : (
|
||||
<>测试 API 连接</>
|
||||
)}
|
||||
</button>
|
||||
{insightTestResult && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: insightTestResult.success ? 'var(--color-success, #22c55e)' : 'var(--color-danger, #ef4444)' }}>
|
||||
{insightTestResult.success ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
|
||||
{insightTestResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* 行为配置 */}
|
||||
<div className="form-group">
|
||||
<label>沉默联系人阈值(天)</label>
|
||||
<span className="form-hint">
|
||||
当你与某个私聊联系人超过此天数没有发过消息时,AI 会主动扫描并尝试给出见解。每 4 小时扫描一次。
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={aiInsightSilenceDays}
|
||||
min={1}
|
||||
max={365}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, parseInt(e.target.value, 10) || 3)
|
||||
setAiInsightSilenceDays(val)
|
||||
scheduleConfigSave('aiInsightSilenceDays', () => configService.setAiInsightSilenceDays(val))
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>允许发送近期对话内容用于分析</label>
|
||||
<span className="form-hint">
|
||||
开启后,AI 见解触发时会将该联系人最近 40 条真实聊天记录一并发送给 AI,使其能够基于真实内容给出有意义的分析,而非泛泛而谈。
|
||||
<br />
|
||||
<strong>关闭时</strong>:AI 仅知道"与某人沉默了 N 天"等统计摘要,输出质量会显著降低。
|
||||
<br />
|
||||
<strong>开启时</strong>:聊天文本内容(不含图片、语音)会通过你配置的 API 发送给你选择的模型提供商。请确认你信任该服务商。
|
||||
</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{aiInsightAllowContext ? '已授权' : '未授权'}</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={aiInsightAllowContext}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.checked
|
||||
setAiInsightAllowContext(val)
|
||||
await configService.setAiInsightAllowContext(val)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
{/* 工作原理说明 */}
|
||||
<div className="form-group">
|
||||
<label>工作原理</label>
|
||||
<div className="api-docs">
|
||||
<div className="api-item">
|
||||
<p className="api-desc" style={{ lineHeight: 1.7 }}>
|
||||
<strong>触发方式一:活跃会话分析</strong> — 每当微信数据库变化(即你收到新消息)时,经过 500ms 防抖后,对最近活跃的私聊会话进行分析。<br />
|
||||
<strong>触发方式二:沉默扫描</strong> — 每 4 小时独立扫描一次,对超过阈值天数无消息的联系人发出提醒。<br />
|
||||
<strong>时间观念</strong> — 每次调用时,AI 会收到今天已向该联系人和全局发出过多少次见解,由 AI 自行决定是否需要克制。<br />
|
||||
<strong>隐私</strong> — 所有分析请求均直接从你的电脑发往你填写的 API 地址,不经过任何 WeFlow 服务器。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -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()}
|
||||
|
||||
@@ -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<string> {
|
||||
export async function setHttpApiHost(host: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
|
||||
}
|
||||
|
||||
// ─── AI 见解 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAiInsightEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiInsightEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getAiInsightApiBaseUrl(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiInsightApiBaseUrl(url: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL, url)
|
||||
}
|
||||
|
||||
export async function getAiInsightApiKey(): Promise<string> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
export async function setAiInsightApiKey(key: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_API_KEY, key)
|
||||
}
|
||||
|
||||
export async function getAiInsightApiModel(): Promise<string> {
|
||||
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<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_API_MODEL, model)
|
||||
}
|
||||
|
||||
export async function getAiInsightSilenceDays(): Promise<number> {
|
||||
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<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_SILENCE_DAYS, days)
|
||||
}
|
||||
|
||||
export async function getAiInsightAllowContext(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setAiInsightAllowContext(allow: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AI_INSIGHT_ALLOW_CONTEXT, allow)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user