mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-06 07:26:48 +00:00
@@ -59,6 +59,7 @@ interface ConfigSchema {
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
aiInsightNotificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
@@ -192,6 +193,7 @@ export class ConfigService {
|
||||
ignoredUpdateVersion: '',
|
||||
updateChannel: 'auto',
|
||||
notificationEnabled: true,
|
||||
aiInsightNotificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
|
||||
292
electron/services/insightRecordService.ts
Normal file
292
electron/services/insightRecordService.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||
|
||||
export interface InsightRecordLog {
|
||||
endpoint: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
allowContext: boolean
|
||||
contextCount: number
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
rawOutput: string
|
||||
finalInsight: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface InsightRecord {
|
||||
id: string
|
||||
accountScope: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
log: InsightRecordLog
|
||||
}
|
||||
|
||||
export interface InsightRecordSummary {
|
||||
id: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
}
|
||||
|
||||
export interface InsightRecordContactFacet {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface InsightRecordFilters {
|
||||
keyword?: string
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface InsightRecordListResult {
|
||||
success: boolean
|
||||
records: InsightRecordSummary[]
|
||||
total: number
|
||||
todayCount: number
|
||||
unreadCount: number
|
||||
contacts: InsightRecordContactFacet[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
class InsightRecordService {
|
||||
private readonly maxRecordsPerScope = 1000
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private records: InsightRecord[] = []
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-insight-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
const filePath = this.resolveFilePath()
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
|
||||
} else if (Array.isArray(parsed?.records)) {
|
||||
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
|
||||
}
|
||||
} catch {
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
|
||||
} catch {
|
||||
// Keep insight generation non-blocking even if local persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentAccountScope(): string {
|
||||
const config = ConfigService.getInstance()
|
||||
const myWxid = String(config.get('myWxid') || '').trim()
|
||||
if (myWxid) return `wxid:${myWxid}`
|
||||
|
||||
const dbPath = String(config.get('dbPath') || '').trim()
|
||||
if (dbPath) {
|
||||
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
|
||||
return `db:${hash}`
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
private getStartOfToday(): number {
|
||||
const date = new Date()
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
private toSummary(record: InsightRecord): InsightRecordSummary {
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
sessionId: record.sessionId,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
triggerReason: record.triggerReason,
|
||||
insight: record.insight,
|
||||
read: record.read
|
||||
}
|
||||
}
|
||||
|
||||
private getScopedRecords(): InsightRecord[] {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
return this.records.filter((record) => record.accountScope === scope)
|
||||
}
|
||||
|
||||
addRecord(input: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
log: InsightRecordLog
|
||||
}): InsightRecord {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const now = Date.now()
|
||||
const record: InsightRecord = {
|
||||
id: randomUUID(),
|
||||
accountScope: scope,
|
||||
createdAt: now,
|
||||
sessionId: input.sessionId,
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
triggerReason: input.triggerReason,
|
||||
insight: input.insight,
|
||||
read: false,
|
||||
log: input.log
|
||||
}
|
||||
|
||||
this.records.push(record)
|
||||
const scopedRecords = this.records
|
||||
.filter((item) => item.accountScope === scope)
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
|
||||
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
|
||||
this.persist()
|
||||
return record
|
||||
}
|
||||
|
||||
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
|
||||
try {
|
||||
const allScoped = this.getScopedRecords()
|
||||
const todayStart = this.getStartOfToday()
|
||||
const contactsMap = new Map<string, InsightRecordContactFacet>()
|
||||
for (const record of allScoped) {
|
||||
const existing = contactsMap.get(record.sessionId)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
} else {
|
||||
contactsMap.set(record.sessionId, {
|
||||
sessionId: record.sessionId,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
const sessionId = String(filters.sessionId || '').trim()
|
||||
const startTime = Number(filters.startTime || 0)
|
||||
const endTime = Number(filters.endTime || 0)
|
||||
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
|
||||
|
||||
const filtered = allScoped
|
||||
.filter((record) => {
|
||||
if (sessionId && record.sessionId !== sessionId) return false
|
||||
if (startTime > 0 && record.createdAt < startTime) return false
|
||||
if (endTime > 0 && record.createdAt > endTime) return false
|
||||
if (keyword) {
|
||||
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
|
||||
if (!haystack.includes(keyword)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
|
||||
total: filtered.length,
|
||||
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
|
||||
unreadCount: allScoped.filter((record) => !record.read).length,
|
||||
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
records: [],
|
||||
total: 0,
|
||||
todayCount: 0,
|
||||
unreadCount: 0,
|
||||
contacts: [],
|
||||
error: (error as Error).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const normalizedId = String(id || '').trim()
|
||||
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||
return { success: true, record }
|
||||
}
|
||||
|
||||
markRecordRead(id: string): { success: boolean; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const normalizedId = String(id || '').trim()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||
if (!record.read) {
|
||||
record.read = true
|
||||
this.persist()
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const sessionId = String(filters.sessionId || '').trim()
|
||||
const startTime = Number(filters.startTime || 0)
|
||||
const endTime = Number(filters.endTime || 0)
|
||||
let removed = 0
|
||||
this.records = this.records.filter((record) => {
|
||||
if (record.accountScope !== scope) return true
|
||||
if (sessionId && record.sessionId !== sessionId) return true
|
||||
if (startTime > 0 && record.createdAt < startTime) return true
|
||||
if (endTime > 0 && record.createdAt > endTime) return true
|
||||
removed += 1
|
||||
return false
|
||||
})
|
||||
this.persist()
|
||||
return { success: true, removed }
|
||||
}
|
||||
}
|
||||
|
||||
export const insightRecordService = new InsightRecordService()
|
||||
@@ -15,14 +15,13 @@
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
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'
|
||||
import { showNotification } from '../windows/notificationWindow'
|
||||
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +40,7 @@ const API_MAX_TOKENS_DEFAULT = 200
|
||||
const API_MAX_TOKENS_MIN = 1
|
||||
const API_MAX_TOKENS_MAX = 65_535
|
||||
const API_TEMPERATURE = 0.7
|
||||
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
@@ -85,60 +85,12 @@ type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||
|
||||
let debugLogWriteQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function formatDebugTimestamp(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
function insightDebugLine(_level: InsightLogLevel, _message: string): void {
|
||||
// Desktop debug log export has been replaced by per-insight request logs.
|
||||
}
|
||||
|
||||
function getInsightDebugLogFilePath(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return path.join(app.getPath('desktop'), `weflow-ai-insight-debug-${year}-${month}-${day}.log`)
|
||||
}
|
||||
|
||||
function isInsightDebugLogEnabled(): boolean {
|
||||
try {
|
||||
return ConfigService.getInstance().get('aiInsightDebugLogEnabled') === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function appendInsightDebugText(text: string): void {
|
||||
if (!isInsightDebugLogEnabled()) return
|
||||
|
||||
let logFilePath = ''
|
||||
try {
|
||||
logFilePath = getInsightDebugLogFilePath()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogWriteQueue = debugLogWriteQueue
|
||||
.then(() => fs.promises.appendFile(logFilePath, text, 'utf8'))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function insightDebugLine(level: InsightLogLevel, message: string): void {
|
||||
appendInsightDebugText(`[${formatDebugTimestamp()}] [${level}] ${message}\n`)
|
||||
}
|
||||
|
||||
function insightDebugSection(level: InsightLogLevel, title: string, payload: unknown): void {
|
||||
const content = typeof payload === 'string'
|
||||
? payload
|
||||
: JSON.stringify(payload, null, 2)
|
||||
|
||||
appendInsightDebugText(
|
||||
`\n========== [${formatDebugTimestamp()}] [${level}] ${title} ==========\n${content}\n========== END ==========\n`
|
||||
)
|
||||
function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
|
||||
// Desktop debug log export has been replaced by per-insight request logs.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -516,9 +468,15 @@ class InsightService {
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'activity'
|
||||
triggerReason: 'test'
|
||||
})
|
||||
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||
return {
|
||||
success: true,
|
||||
message: notificationEnabled
|
||||
? `已向「${displayName}」发送测试见解,请查看通知弹窗`
|
||||
: `已生成「${displayName}」的测试见解,AI 见解消息通知当前已关闭`
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, message: `测试失败:${(e as Error).message}` }
|
||||
}
|
||||
@@ -1139,7 +1097,7 @@ ${topMentionText}
|
||||
private async generateInsightForSession(params: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
triggerReason: 'activity' | 'silence'
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
@@ -1150,6 +1108,13 @@ ${topMentionText}
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
|
||||
let resolvedAvatarUrl: string | undefined
|
||||
try {
|
||||
const contact = await chatService.getContactAvatar(sessionId)
|
||||
resolvedAvatarUrl = String(contact?.avatarUrl || '').trim() || undefined
|
||||
} catch {
|
||||
resolvedAvatarUrl = undefined
|
||||
}
|
||||
|
||||
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||
|
||||
@@ -1228,6 +1193,7 @@ ${topMentionText}
|
||||
)
|
||||
|
||||
try {
|
||||
const apiStartedAt = Date.now()
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
@@ -1236,6 +1202,7 @@ ${topMentionText}
|
||||
API_TIMEOUT_MS,
|
||||
maxTokens
|
||||
)
|
||||
const apiDurationMs = Date.now() - apiStartedAt
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
|
||||
@@ -1249,15 +1216,45 @@ ${topMentionText}
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${resolvedDisplayName}`
|
||||
const recordLog: InsightRecordLog = {
|
||||
endpoint,
|
||||
model,
|
||||
maxTokens,
|
||||
temperature: API_TEMPERATURE,
|
||||
triggerReason,
|
||||
allowContext,
|
||||
contextCount,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
rawOutput: result,
|
||||
finalInsight: insight,
|
||||
durationMs: apiDurationMs,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
const record = insightRecordService.addRecord({
|
||||
sessionId,
|
||||
displayName: resolvedDisplayName,
|
||||
avatarUrl: resolvedAvatarUrl,
|
||||
triggerReason,
|
||||
insight,
|
||||
log: recordLog
|
||||
})
|
||||
|
||||
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||
if (insightNotificationEnabled) {
|
||||
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||
notif.show()
|
||||
// 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
|
||||
await showNotification({
|
||||
title: notifTitle,
|
||||
content: insight,
|
||||
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
|
||||
sessionId,
|
||||
insightRecordId: record.id,
|
||||
channel: 'ai-insight'
|
||||
})
|
||||
} else {
|
||||
insightLog('WARN', '当前系统不支持原生通知')
|
||||
insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
}
|
||||
|
||||
// 渠道二:Telegram Bot 推送(可选)
|
||||
@@ -1278,7 +1275,7 @@ ${topMentionText}
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
|
||||
insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`)
|
||||
this.recordTrigger(sessionId)
|
||||
} catch (e) {
|
||||
insightDebugSection(
|
||||
|
||||
@@ -6,10 +6,13 @@ export interface LinuxNotificationData {
|
||||
title: string;
|
||||
content: string;
|
||||
avatarUrl?: string;
|
||||
channel?: string;
|
||||
insightRecordId?: string;
|
||||
targetRoute?: string;
|
||||
expireTimeout?: number;
|
||||
}
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
type NotificationCallback = (payload: unknown) => void;
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
@@ -31,10 +34,10 @@ function clearNotificationState(notificationId: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
function triggerNotificationCallback(payload: unknown): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
@@ -69,6 +72,15 @@ export async function showLinuxNotification(
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.channel === "ai-insight" && data.insightRecordId) {
|
||||
triggerNotificationCallback({
|
||||
sessionId: data.sessionId,
|
||||
channel: data.channel,
|
||||
insightRecordId: data.insightRecordId,
|
||||
targetRoute: data.targetRoute,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user