diff --git a/electron/main.ts b/electron/main.ts index 55464cd..23f7ef2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' +import { insightRecordService } from './services/insightRecordService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' import { backupService } from './services/backupService' @@ -734,14 +735,41 @@ const focusMainWindowAndNavigate = (sessionId: string): void => { targetWindow.webContents.send('navigate-to-session', sessionId) } +const focusMainWindowAndNavigateRoute = (route: string): void => { + const targetWindow = mainWindow + if (!targetWindow || targetWindow.isDestroyed()) return + if (targetWindow.isMinimized()) targetWindow.restore() + targetWindow.show() + targetWindow.focus() + targetWindow.webContents.send('navigate-to-route', route) +} + +const handleNotificationClickNavigation = (payload: unknown): void => { + if (payload && typeof payload === 'object') { + const data = payload as { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string } + const targetRoute = String(data.targetRoute || '').trim() + if (targetRoute.startsWith('/')) { + focusMainWindowAndNavigateRoute(targetRoute) + return + } + if (data.channel === 'ai-insight' && data.insightRecordId) { + focusMainWindowAndNavigateRoute(`/insight-inbox?recordId=${encodeURIComponent(String(data.insightRecordId))}`) + return + } + focusMainWindowAndNavigate(String(data.sessionId || '')) + return + } + focusMainWindowAndNavigate(String(payload || '')) +} + const ensureNotificationNavigateHandlerRegistered = (): void => { if (notificationNavigateHandlerRegistered) return notificationNavigateHandlerRegistered = true - ipcMain.on('notification-clicked', (_event, sessionId) => { - focusMainWindowAndNavigate(String(sessionId || '')) + ipcMain.on('notification-clicked', (_event, payload) => { + handleNotificationClickNavigation(payload) }) - setNotificationNavigateHandler((sessionId: string) => { - focusMainWindowAndNavigate(String(sessionId || '')) + setNotificationNavigateHandler((payload: unknown) => { + handleNotificationClickNavigation(payload) }) } @@ -1734,6 +1762,33 @@ function registerIpcHandlers() { return insightService.getTodayStats() }) + ipcMain.handle('insight:listRecords', async (_, filters?: { + keyword?: string + sessionId?: string + startTime?: number + endTime?: number + limit?: number + offset?: number + }) => { + return insightRecordService.listRecords(filters || {}) + }) + + ipcMain.handle('insight:getRecord', async (_, id: string) => { + return insightRecordService.getRecord(id) + }) + + ipcMain.handle('insight:markRecordRead', async (_, id: string) => { + return insightRecordService.markRecordRead(id) + }) + + ipcMain.handle('insight:clearRecords', async (_, filters?: { + sessionId?: string + startTime?: number + endTime?: number + }) => { + return insightRecordService.clearRecords(filters || {}) + }) + ipcMain.handle('insight:triggerTest', async () => { return insightService.triggerTest() }) diff --git a/electron/preload.ts b/electron/preload.ts index b4f6371..bb175c0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', { notification: { show: (data: any) => ipcRenderer.invoke('notification:show', data), close: () => ipcRenderer.invoke('notification:close'), - click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId), + click: (payload: any) => ipcRenderer.send('notification-clicked', payload), ready: () => ipcRenderer.send('notification:ready'), resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }), onShow: (callback: (event: any, data: any) => void) => { @@ -24,6 +24,11 @@ contextBridge.exposeInMainWorld('electronAPI', { const listener = (_: any, sessionId: string) => callback(sessionId) ipcRenderer.on('navigate-to-session', listener) return () => ipcRenderer.removeListener('navigate-to-session', listener) + }, + onNavigateToRoute: (callback: (route: string) => void) => { + const listener = (_: any, route: string) => callback(route) + ipcRenderer.on('navigate-to-route', listener) + return () => ipcRenderer.removeListener('navigate-to-route', listener) } }, @@ -573,6 +578,10 @@ contextBridge.exposeInMainWorld('electronAPI', { insight: { testConnection: () => ipcRenderer.invoke('insight:testConnection'), getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'), + listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters), + getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id), + markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id), + clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters), triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), generateFootprintInsight: (payload: { rangeLabel: string diff --git a/electron/services/config.ts b/electron/services/config.ts index d7ba1cc..b0f43d3 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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: [], diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts new file mode 100644 index 0000000..9539ea7 --- /dev/null +++ b/electron/services/insightRecordService.ts @@ -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() + 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() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 5554a29..ebf82b1 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -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 = 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 { 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( diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts index 111626c..931a16e 100644 --- a/electron/services/linuxNotificationService.ts +++ b/electron/services/linuxNotificationService.ts @@ -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); } diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index 21bbf01..e4a5c57 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -9,10 +9,10 @@ let linuxNotificationService: | null = null; // 用于处理通知点击的回调函数(在Linux上用于导航到会话) -let onNotificationNavigate: ((sessionId: string) => void) | null = null; +let onNotificationNavigate: ((payload: unknown) => void) | null = null; export function setNotificationNavigateHandler( - callback: (sessionId: string) => void, + callback: (payload: unknown) => void, ) { onNotificationNavigate = callback; } @@ -109,25 +109,33 @@ export function createNotificationWindow() { export async function showNotification(data: any) { // 先检查配置 const config = ConfigService.getInstance(); - const enabled = await config.get("notificationEnabled"); - if (enabled === false) return; // 默认为 true - - // 检查会话过滤 - const filterMode = config.get("notificationFilterMode") || "all"; - const filterList = config.get("notificationFilterList") || []; const sessionId = typeof data.sessionId === "string" ? data.sessionId : ""; - // 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响 - const isSystemNotification = sessionId.startsWith("weflow-"); + const channel = typeof data.channel === "string" ? data.channel : ""; + const isAiInsightNotification = channel === "ai-insight"; - if (!isSystemNotification && filterMode !== "all") { - const isInList = sessionId !== "" && filterList.includes(sessionId); - if (filterMode === "whitelist" && !isInList) { - // 白名单模式:不在列表中则不显示(空列表视为全部拦截) - return; - } - if (filterMode === "blacklist" && isInList) { - // 黑名单模式:在列表中则不显示 - return; + if (isAiInsightNotification) { + const enabled = await config.get("aiInsightNotificationEnabled"); + if (enabled === false) return; // 默认为 true + } else { + const enabled = await config.get("notificationEnabled"); + if (enabled === false) return; // 默认为 true + + // 检查会话过滤 + const filterMode = config.get("notificationFilterMode") || "all"; + const filterList = config.get("notificationFilterList") || []; + // 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响 + const isSystemNotification = sessionId.startsWith("weflow-"); + + if (!isSystemNotification && filterMode !== "all") { + const isInList = sessionId !== "" && filterList.includes(sessionId); + if (filterMode === "whitelist" && !isInList) { + // 白名单模式:不在列表中则不显示(空列表视为全部拦截) + return; + } + if (filterMode === "blacklist" && isInList) { + // 黑名单模式:在列表中则不显示 + return; + } } } @@ -176,6 +184,9 @@ async function showLinuxNotification(data: any) { content: data.content, avatarUrl: data.avatarUrl, sessionId: data.sessionId, + channel: data.channel, + insightRecordId: data.insightRecordId, + targetRoute: data.targetRoute, expireTimeout: 5000, }; @@ -249,14 +260,14 @@ export async function registerNotificationHandlers() { await linuxNotificationModule.initLinuxNotificationService(); // 在Linux上注册通知点击回调 - linuxNotificationModule.onNotificationAction((sessionId: string) => { + linuxNotificationModule.onNotificationAction((payload: unknown) => { console.log( "[NotificationWindow] Linux notification clicked, sessionId:", - sessionId, + payload, ); // 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。 if (onNotificationNavigate) { - onNotificationNavigate(sessionId); + onNotificationNavigate(payload); } else { // 如果尚未设置处理程序,则通过ipcMain发出事件 // 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。 diff --git a/public/assets/insight/AI_Insight.png b/public/assets/insight/AI_Insight.png new file mode 100644 index 0000000..35afe67 Binary files /dev/null and b/public/assets/insight/AI_Insight.png differ diff --git a/src/App.tsx b/src/App.tsx index 5834978..3df7ca3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' import AccountManagementPage from './pages/AccountManagementPage' import BackupPage from './pages/BackupPage' +import InsightInboxPage from './pages/InsightInboxPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' @@ -319,6 +320,19 @@ function App() { } }, [navigate, isNotificationWindow]) + useEffect(() => { + if (isNotificationWindow) return + + const removeListener = window.electronAPI?.notification?.onNavigateToRoute?.((route: string) => { + if (!route || !route.startsWith('/')) return + navigate(route, { replace: true }) + }) + + return () => { + removeListener?.() + } + }, [navigate, isNotificationWindow]) + // 解锁后显示暂存的更新弹窗 useEffect(() => { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { @@ -703,6 +717,7 @@ function App() {