Merge pull request #909 from Jasonzhu1207/main

feat: add insight inbox
This commit is contained in:
cc
2026-05-05 14:33:03 +08:00
committed by GitHub
17 changed files with 1709 additions and 132 deletions

View File

@@ -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: [],

View 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()

View File

@@ -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(

View File

@@ -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);
}