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

@@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService' import { messagePushService } from './services/messagePushService'
import { insightService } from './services/insightService' import { insightService } from './services/insightService'
import { insightRecordService } from './services/insightRecordService'
import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
import { bizService } from './services/bizService' import { bizService } from './services/bizService'
import { backupService } from './services/backupService' import { backupService } from './services/backupService'
@@ -734,14 +735,41 @@ const focusMainWindowAndNavigate = (sessionId: string): void => {
targetWindow.webContents.send('navigate-to-session', sessionId) 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 => { const ensureNotificationNavigateHandlerRegistered = (): void => {
if (notificationNavigateHandlerRegistered) return if (notificationNavigateHandlerRegistered) return
notificationNavigateHandlerRegistered = true notificationNavigateHandlerRegistered = true
ipcMain.on('notification-clicked', (_event, sessionId) => { ipcMain.on('notification-clicked', (_event, payload) => {
focusMainWindowAndNavigate(String(sessionId || '')) handleNotificationClickNavigation(payload)
}) })
setNotificationNavigateHandler((sessionId: string) => { setNotificationNavigateHandler((payload: unknown) => {
focusMainWindowAndNavigate(String(sessionId || '')) handleNotificationClickNavigation(payload)
}) })
} }
@@ -1734,6 +1762,33 @@ function registerIpcHandlers() {
return insightService.getTodayStats() 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 () => { ipcMain.handle('insight:triggerTest', async () => {
return insightService.triggerTest() return insightService.triggerTest()
}) })

View File

@@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
notification: { notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data), show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'), 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'), ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }), resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => { onShow: (callback: (event: any, data: any) => void) => {
@@ -24,6 +24,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, sessionId: string) => callback(sessionId) const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener) ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('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: { insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'), testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'), 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'), triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
generateFootprintInsight: (payload: { generateFootprintInsight: (payload: {
rangeLabel: string rangeLabel: string

View File

@@ -59,6 +59,7 @@ interface ConfigSchema {
// 通知 // 通知
notificationEnabled: boolean notificationEnabled: boolean
aiInsightNotificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[] notificationFilterList: string[]
@@ -192,6 +193,7 @@ export class ConfigService {
ignoredUpdateVersion: '', ignoredUpdateVersion: '',
updateChannel: 'auto', updateChannel: 'auto',
notificationEnabled: true, notificationEnabled: true,
aiInsightNotificationEnabled: true,
notificationPosition: 'top-right', notificationPosition: 'top-right',
notificationFilterMode: 'all', notificationFilterMode: 'all',
notificationFilterList: [], 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 https from 'https'
import http from 'http' import http from 'http'
import fs from 'fs'
import path from 'path'
import { URL } from 'url' import { URL } from 'url'
import { app, Notification } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService' import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService' import { snsService } from './snsService'
import { weiboService } from './social/weiboService' 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_MIN = 1
const API_MAX_TOKENS_MAX = 65_535 const API_MAX_TOKENS_MAX = 65_535
const API_TEMPERATURE = 0.7 const API_TEMPERATURE = 0.7
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
/** 沉默天数阈值默认值 */ /** 沉默天数阈值默认值 */
const DEFAULT_SILENCE_DAYS = 3 const DEFAULT_SILENCE_DAYS = 3
@@ -85,60 +85,12 @@ type InsightFilterMode = 'whitelist' | 'blacklist'
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
let debugLogWriteQueue: Promise<void> = Promise.resolve() function insightDebugLine(_level: InsightLogLevel, _message: string): void {
// Desktop debug log export has been replaced by per-insight request logs.
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 getInsightDebugLogFilePath(date: Date = new Date()): string { function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
const year = date.getFullYear() // Desktop debug log export has been replaced by per-insight request logs.
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`
)
} }
/** /**
@@ -516,9 +468,15 @@ class InsightService {
await this.generateInsightForSession({ await this.generateInsightForSession({
sessionId, sessionId,
displayName, 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) { } catch (e) {
return { success: false, message: `测试失败:${(e as Error).message}` } return { success: false, message: `测试失败:${(e as Error).message}` }
} }
@@ -1139,7 +1097,7 @@ ${topMentionText}
private async generateInsightForSession(params: { private async generateInsightForSession(params: {
sessionId: string sessionId: string
displayName: string displayName: string
triggerReason: 'activity' | 'silence' triggerReason: InsightRecordTriggerReason
silentDays?: number silentDays?: number
}): Promise<void> { }): Promise<void> {
const { sessionId, displayName, triggerReason, silentDays } = params const { sessionId, displayName, triggerReason, silentDays } = params
@@ -1150,6 +1108,13 @@ ${topMentionText}
const allowContext = this.config.get('aiInsightAllowContext') as boolean const allowContext = this.config.get('aiInsightAllowContext') as boolean
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40 const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName) 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 ? '已配置' : '未配置'}`) insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
@@ -1228,6 +1193,7 @@ ${topMentionText}
) )
try { try {
const apiStartedAt = Date.now()
const result = await callApi( const result = await callApi(
apiBaseUrl, apiBaseUrl,
apiKey, apiKey,
@@ -1236,6 +1202,7 @@ ${topMentionText}
API_TIMEOUT_MS, API_TIMEOUT_MS,
maxTokens maxTokens
) )
const apiDurationMs = Date.now() - apiStartedAt
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`) insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result) insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
@@ -1249,15 +1216,45 @@ ${topMentionText}
const insight = result.slice(0, 120) const insight = result.slice(0, 120)
const notifTitle = `见解 · ${resolvedDisplayName}` 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 原生系统通知 // 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
if (Notification.isSupported()) { await showNotification({
const notif = new Notification({ title: notifTitle, body: insight, silent: false }) title: notifTitle,
notif.show() content: insight,
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
sessionId,
insightRecordId: record.id,
channel: 'ai-insight'
})
} else { } else {
insightLog('WARN', '当前系统不支持原生通知') insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
} }
// 渠道二Telegram Bot 推送(可选) // 渠道二Telegram Bot 推送(可选)
@@ -1278,7 +1275,7 @@ ${topMentionText}
} }
} }
insightLog('INFO', ` ${resolvedDisplayName} 推送见解`) insightLog('INFO', `完成 ${resolvedDisplayName} 的见解处理`)
this.recordTrigger(sessionId) this.recordTrigger(sessionId)
} catch (e) { } catch (e) {
insightDebugSection( insightDebugSection(

View File

@@ -6,10 +6,13 @@ export interface LinuxNotificationData {
title: string; title: string;
content: string; content: string;
avatarUrl?: string; avatarUrl?: string;
channel?: string;
insightRecordId?: string;
targetRoute?: string;
expireTimeout?: number; expireTimeout?: number;
} }
type NotificationCallback = (sessionId: string) => void; type NotificationCallback = (payload: unknown) => void;
let notificationCallbacks: NotificationCallback[] = []; let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1; 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) { for (const callback of notificationCallbacks) {
try { try {
callback(sessionId); callback(payload);
} catch (error) { } catch (error) {
console.error("[LinuxNotification] Callback error:", error); console.error("[LinuxNotification] Callback error:", error);
} }
@@ -69,6 +72,15 @@ export async function showLinuxNotification(
activeNotifications.set(notificationId, notification); activeNotifications.set(notificationId, notification);
notification.on("click", () => { 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) { if (data.sessionId) {
triggerNotificationCallback(data.sessionId); triggerNotificationCallback(data.sessionId);
} }

View File

@@ -9,10 +9,10 @@ let linuxNotificationService:
| null = null; | null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话 // 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((sessionId: string) => void) | null = null; let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler( export function setNotificationNavigateHandler(
callback: (sessionId: string) => void, callback: (payload: unknown) => void,
) { ) {
onNotificationNavigate = callback; onNotificationNavigate = callback;
} }
@@ -109,25 +109,33 @@ export function createNotificationWindow() {
export async function showNotification(data: any) { export async function showNotification(data: any) {
// 先检查配置 // 先检查配置
const config = ConfigService.getInstance(); 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 : ""; const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响 const channel = typeof data.channel === "string" ? data.channel : "";
const isSystemNotification = sessionId.startsWith("weflow-"); const isAiInsightNotification = channel === "ai-insight";
if (!isSystemNotification && filterMode !== "all") { if (isAiInsightNotification) {
const isInList = sessionId !== "" && filterList.includes(sessionId); const enabled = await config.get("aiInsightNotificationEnabled");
if (filterMode === "whitelist" && !isInList) { if (enabled === false) return; // 默认为 true
// 白名单模式:不在列表中则不显示(空列表视为全部拦截) } else {
return; const enabled = await config.get("notificationEnabled");
} if (enabled === false) return; // 默认为 true
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示 // 检查会话过滤
return; 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, content: data.content,
avatarUrl: data.avatarUrl, avatarUrl: data.avatarUrl,
sessionId: data.sessionId, sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000, expireTimeout: 5000,
}; };
@@ -249,14 +260,14 @@ export async function registerNotificationHandlers() {
await linuxNotificationModule.initLinuxNotificationService(); await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调 // 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((sessionId: string) => { linuxNotificationModule.onNotificationAction((payload: unknown) => {
console.log( console.log(
"[NotificationWindow] Linux notification clicked, sessionId:", "[NotificationWindow] Linux notification clicked, sessionId:",
sessionId, payload,
); );
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。 // 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) { if (onNotificationNavigate) {
onNotificationNavigate(sessionId); onNotificationNavigate(payload);
} else { } else {
// 如果尚未设置处理程序则通过ipcMain发出事件 // 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。 // 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -28,6 +28,7 @@ import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow' import NotificationWindow from './pages/NotificationWindow'
import AccountManagementPage from './pages/AccountManagementPage' import AccountManagementPage from './pages/AccountManagementPage'
import BackupPage from './pages/BackupPage' import BackupPage from './pages/BackupPage'
import InsightInboxPage from './pages/InsightInboxPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -319,6 +320,19 @@ function App() {
} }
}, [navigate, isNotificationWindow]) }, [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(() => { useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -703,6 +717,7 @@ function App() {
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} /> <Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/insight-inbox" element={<InsightInboxPage />} />
<Route path="/biz" element={<BizPage />} /> <Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} /> <Route path="/resources" element={<ResourcesPage />} />

View File

@@ -7,6 +7,9 @@ import './NotificationToast.scss'
export interface NotificationData { export interface NotificationData {
id: string id: string
sessionId: string sessionId: string
channel?: string
insightRecordId?: string
targetRoute?: string
avatarUrl?: string avatarUrl?: string
title: string title: string
content: string content: string
@@ -16,7 +19,7 @@ export interface NotificationData {
interface NotificationToastProps { interface NotificationToastProps {
data: NotificationData | null data: NotificationData | null
onClose: () => void onClose: () => void
onClick: (sessionId: string) => void onClick: (data: NotificationData) => void
duration?: number duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean isStatic?: boolean
@@ -64,7 +67,7 @@ export function NotificationToast({
setIsVisible(false) setIsVisible(false)
setTimeout(() => { setTimeout(() => {
onClose() onClose()
onClick(currentData.sessionId) onClick(currentData)
}, 300) }, 300)
} }

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } from 'lucide-react' import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config' import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -344,6 +344,15 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
<NavLink
to="/insight-inbox"
className={`nav-item ${isActive('/insight-inbox') ? 'active' : ''}`}
title={collapsed ? '灵感信箱' : undefined}
>
<span className="nav-icon"><Sparkles size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */} {/* 通讯录 */}
<NavLink <NavLink
to="/contacts" to="/contacts"

View File

@@ -0,0 +1,612 @@
.insight-inbox-page {
--insight-panel-width: 360px;
--insight-card-bg: var(--bg-secondary);
display: flex;
height: calc(100% + 48px);
margin: -24px;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
}
.insight-inbox-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 18px 24px 14px;
}
.insight-inbox-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 4px 12px;
border-bottom: 1px solid var(--border-color);
}
.insight-inbox-title-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.insight-inbox-title-line {
display: flex;
align-items: center;
gap: 10px;
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
}
.insight-inbox-logo {
width: 30px;
height: 30px;
border-radius: 8px;
object-fit: cover;
}
.insight-inbox-stats {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-secondary);
span + span::before {
content: '';
display: inline-block;
width: 3px;
height: 3px;
margin-right: 10px;
border-radius: 50%;
background: var(--text-tertiary);
vertical-align: middle;
}
}
.insight-icon-btn,
.insight-action-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
border-color: var(--text-secondary);
}
}
.insight-icon-btn {
width: 40px;
height: 40px;
border-radius: 10px;
}
.insight-action-btn {
width: 30px;
height: 30px;
border-radius: 8px;
&.code {
color: var(--primary);
}
}
.spinning {
animation: insight-spin 0.9s linear infinite;
}
@keyframes insight-spin {
to {
transform: rotate(360deg);
}
}
.insight-focus-bar {
margin: 12px 4px 0;
padding: 9px 12px;
border: 1px solid rgba(91, 147, 144, 0.22);
border-radius: 10px;
background: rgba(91, 147, 144, 0.08);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
button {
margin-left: auto;
border: none;
background: transparent;
color: var(--primary);
cursor: pointer;
font-size: 13px;
}
}
.insight-record-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 4px 22px;
}
.insight-date-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 18px;
}
.insight-date-label {
position: sticky;
top: 0;
z-index: 1;
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 86%, transparent);
color: var(--text-tertiary);
font-size: 12px;
backdrop-filter: blur(10px);
}
.insight-card {
display: flex;
gap: 14px;
padding: 18px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--insight-card-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
&:hover {
border-color: rgba(91, 147, 144, 0.28);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.07);
}
&.unread {
border-left: 4px solid var(--primary);
}
&.focused {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(91, 147, 144, 0.14), 0 12px 32px rgba(0, 0, 0, 0.08);
}
}
.insight-card-avatar {
flex: 0 0 auto;
}
.insight-card-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.insight-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.insight-recipient {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.insight-recipient-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.insight-recipient-name {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insight-session-id {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-tertiary);
}
.insight-card-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
.insight-trigger-pill {
padding: 5px 8px;
border-radius: 999px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
&.silence {
color: #8a5a00;
background: rgba(245, 158, 11, 0.14);
}
&.test {
color: #5b55a0;
background: rgba(99, 102, 241, 0.12);
}
}
.insight-time {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
}
.insight-body {
margin: 0;
color: var(--text-primary);
font-size: 15px;
line-height: 1.72;
white-space: pre-wrap;
word-break: break-word;
}
.insight-filter-panel {
width: var(--insight-panel-width);
flex-shrink: 0;
padding: 24px 24px 18px;
border-left: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 70%, var(--bg-primary));
overflow-y: auto;
}
.insight-filter-header {
margin-bottom: 18px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
}
.insight-filter-widget {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 14px;
margin-bottom: 14px;
}
.insight-widget-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.insight-widget-count {
margin-left: auto;
color: var(--text-tertiary);
font-weight: 500;
}
.insight-input-wrap {
display: flex;
align-items: center;
gap: 6px;
border-radius: 9px;
background: var(--bg-tertiary);
padding: 0 9px;
input {
min-width: 0;
flex: 1;
height: 38px;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
}
button {
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
display: inline-flex;
padding: 3px;
}
}
.insight-date-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
button {
height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(91, 147, 144, 0.08);
}
}
}
.insight-custom-dates {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-top: 10px;
input {
height: 34px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 10px;
}
}
.contact-filter {
display: flex;
flex-direction: column;
min-height: 260px;
}
.insight-contact-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 420px;
overflow-y: auto;
}
.insight-contact-row {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
gap: 9px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
padding: 7px 8px;
cursor: pointer;
text-align: left;
span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
strong {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 600;
}
&:hover,
&.active {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.all {
margin-top: 10px;
}
}
.insight-empty-state {
min-height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-tertiary);
text-align: center;
strong {
color: var(--text-primary);
font-size: 16px;
}
button {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 8px;
padding: 7px 12px;
cursor: pointer;
}
}
.insight-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
.insight-log-dialog {
width: min(860px, 92vw);
height: min(780px, 84vh);
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.insight-log-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
h3 {
margin: 0 0 4px;
font-size: 16px;
}
span {
color: var(--text-secondary);
font-size: 12px;
}
}
.insight-log-actions {
display: flex;
align-items: center;
gap: 8px;
button {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
min-height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:hover {
color: var(--text-primary);
background: var(--bg-primary);
}
&.close {
width: 32px;
padding: 0;
justify-content: center;
}
}
}
.insight-log-body {
flex: 1;
overflow-y: auto;
padding: 18px;
background: var(--bg-primary);
section {
margin-bottom: 18px;
}
h4 {
margin: 0 0 8px;
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
pre {
margin: 0;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
}
.insight-copy-toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
z-index: 1100;
padding: 9px 14px;
border-radius: 999px;
background: rgba(30, 30, 30, 0.88);
color: #fff;
font-size: 13px;
}
@media (max-width: 980px) {
.insight-inbox-page {
flex-direction: column;
}
.insight-filter-panel {
width: auto;
border-left: none;
border-top: 1px solid var(--border-color);
max-height: 42%;
}
.insight-card-header,
.insight-card-actions {
align-items: flex-start;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,470 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { CalendarDays, Code, Copy, MessageSquare, RefreshCw, Search, Sparkles, X } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import type {
InsightRecord,
InsightRecordContactFacet,
InsightRecordFilters,
InsightRecordListResult,
InsightRecordSummary,
InsightRecordTriggerReason
} from '../types/electron'
import './InsightInboxPage.scss'
const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png'
type DateFilterMode = 'all' | 'today' | 'week' | 'custom'
function getStartOfDay(date: Date): number {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next.getTime()
}
function getEndOfDay(date: Date): number {
const next = new Date(date)
next.setHours(23, 59, 59, 999)
return next.getTime()
}
function formatDateInput(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function parseDateInput(value: string, endOfDay = false): number | undefined {
if (!value) return undefined
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime())) return undefined
return endOfDay ? getEndOfDay(date) : getStartOfDay(date)
}
function formatRecordTime(timestamp: number): string {
return new Date(timestamp).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function formatGroupDate(timestamp: number): string {
const date = new Date(timestamp)
const today = new Date()
const yesterday = new Date()
yesterday.setDate(today.getDate() - 1)
if (getStartOfDay(date) === getStartOfDay(today)) return '今天'
if (getStartOfDay(date) === getStartOfDay(yesterday)) return '昨天'
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function getTriggerLabel(reason: InsightRecordTriggerReason): string {
if (reason === 'silence') return '沉默提醒'
if (reason === 'test') return '测试见解'
return '活跃分析'
}
function buildLogText(record: InsightRecord): string {
const log = record.log
return [
`时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`,
`联系人:${record.displayName} (${record.sessionId})`,
`触发类型:${getTriggerLabel(record.triggerReason)}`,
`接口地址:${log.endpoint}`,
`模型:${log.model}`,
`Max Tokens${log.maxTokens}`,
`Temperature${log.temperature}`,
`耗时:${log.durationMs}ms`,
'',
'系统提示词:',
log.systemPrompt,
'',
'用户提示词:',
log.userPrompt,
'',
'模型输出原文:',
log.rawOutput,
'',
'最终见解:',
log.finalInsight
].join('\n')
}
export default function InsightInboxPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [records, setRecords] = useState<InsightRecordSummary[]>([])
const [contacts, setContacts] = useState<InsightRecordContactFacet[]>([])
const [keyword, setKeyword] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [selectedSessionId, setSelectedSessionId] = useState('')
const [dateMode, setDateMode] = useState<DateFilterMode>('all')
const [customStart, setCustomStart] = useState(formatDateInput(new Date()))
const [customEnd, setCustomEnd] = useState(formatDateInput(new Date()))
const [stats, setStats] = useState({ total: 0, todayCount: 0, unreadCount: 0 })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [focusedRecordId, setFocusedRecordId] = useState(searchParams.get('recordId') || '')
const [logRecord, setLogRecord] = useState<InsightRecord | null>(null)
const [message, setMessage] = useState('')
const dateRange = useMemo(() => {
const now = new Date()
if (dateMode === 'today') {
return { startTime: getStartOfDay(now), endTime: getEndOfDay(now) }
}
if (dateMode === 'week') {
const start = new Date(now)
start.setDate(now.getDate() - 6)
return { startTime: getStartOfDay(start), endTime: getEndOfDay(now) }
}
if (dateMode === 'custom') {
return {
startTime: parseDateInput(customStart),
endTime: parseDateInput(customEnd, true)
}
}
return {}
}, [customEnd, customStart, dateMode])
const filters = useMemo<InsightRecordFilters>(() => ({
keyword: keyword.trim() || undefined,
sessionId: selectedSessionId || undefined,
startTime: dateRange.startTime,
endTime: dateRange.endTime,
limit: 200,
offset: 0
}), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId])
const loadRecords = useCallback(async () => {
setLoading(true)
setError('')
try {
const result: InsightRecordListResult = await window.electronAPI.insight.listRecords(filters)
if (!result.success) {
setError(result.error || '加载灵感信箱失败')
return
}
setRecords(result.records)
setContacts(result.contacts)
setStats({
total: result.total,
todayCount: result.todayCount,
unreadCount: result.unreadCount
})
} catch (err) {
setError((err as Error).message || '加载灵感信箱失败')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => {
void loadRecords()
}, [loadRecords])
useEffect(() => {
const recordId = searchParams.get('recordId') || ''
if (!recordId) return
setFocusedRecordId(recordId)
window.setTimeout(() => {
document.getElementById(`insight-record-${recordId}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, 120)
void window.electronAPI.insight.markRecordRead(recordId)
}, [searchParams])
const groupedRecords = useMemo(() => {
const groups: Array<{ label: string; records: InsightRecordSummary[] }> = []
for (const record of records) {
const label = formatGroupDate(record.createdAt)
const last = groups[groups.length - 1]
if (last?.label === label) {
last.records.push(record)
} else {
groups.push({ label, records: [record] })
}
}
return groups
}, [records])
const filteredContacts = useMemo(() => {
const normalized = contactSearch.trim().toLowerCase()
if (!normalized) return contacts
return contacts.filter((contact) => {
const text = `${contact.displayName}\n${contact.sessionId}`.toLowerCase()
return text.includes(normalized)
})
}, [contactSearch, contacts])
const openChat = (record: InsightRecordSummary) => {
navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`)
}
const copyText = async (text: string, successText: string) => {
try {
await navigator.clipboard.writeText(text)
setMessage(successText)
window.setTimeout(() => setMessage(''), 1800)
} catch {
setMessage('复制失败')
window.setTimeout(() => setMessage(''), 1800)
}
}
const openLog = async (recordId: string) => {
const result = await window.electronAPI.insight.getRecord(recordId)
if (!result.success || !result.record) {
setMessage(result.error || '读取请求日志失败')
window.setTimeout(() => setMessage(''), 1800)
return
}
setLogRecord(result.record)
void window.electronAPI.insight.markRecordRead(recordId)
setRecords((prev) => prev.map((record) => record.id === recordId ? { ...record, read: true } : record))
}
const clearFocusedRecord = () => {
setFocusedRecordId('')
searchParams.delete('recordId')
setSearchParams(searchParams, { replace: true })
}
return (
<div className="insight-inbox-page">
<section className="insight-inbox-main">
<header className="insight-inbox-header">
<div className="insight-inbox-title-block">
<div className="insight-inbox-title-line">
<img src={INSIGHT_AVATAR_URL} alt="" className="insight-inbox-logo" />
<h2></h2>
</div>
<div className="insight-inbox-stats">
<span> {stats.total} </span>
<span> {stats.todayCount} </span>
<span> {stats.unreadCount} </span>
</div>
</div>
<button className="insight-icon-btn" onClick={() => { void loadRecords() }} title="刷新">
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
</button>
</header>
{focusedRecordId && (
<div className="insight-focus-bar">
<Sparkles size={15} />
<span></span>
<button type="button" onClick={clearFocusedRecord}></button>
</div>
)}
<div className="insight-record-scroll">
{error && (
<div className="insight-empty-state">
<span>{error}</span>
<button onClick={() => { void loadRecords() }}></button>
</div>
)}
{!error && loading && records.length === 0 && (
<div className="insight-empty-state">
<RefreshCw size={18} className="spinning" />
<span>...</span>
</div>
)}
{!error && !loading && records.length === 0 && (
<div className="insight-empty-state">
<Sparkles size={36} />
<strong></strong>
<span>AI </span>
</div>
)}
{groupedRecords.map((group) => (
<div className="insight-date-group" key={group.label}>
<div className="insight-date-label">{group.label}</div>
{group.records.map((record) => (
<article
id={`insight-record-${record.id}`}
key={record.id}
className={`insight-card ${record.read ? '' : 'unread'} ${focusedRecordId === record.id ? 'focused' : ''}`}
>
<div className="insight-card-avatar">
<Avatar src={INSIGHT_AVATAR_URL} name="见解" size={44} shape="rounded" lazy={false} />
</div>
<div className="insight-card-content">
<div className="insight-card-header">
<div className="insight-recipient">
<Avatar src={record.avatarUrl} name={record.displayName} size={28} shape="rounded" />
<div className="insight-recipient-text">
<span className="insight-recipient-name"> {record.displayName}</span>
<span className="insight-session-id">{record.sessionId}</span>
</div>
</div>
<div className="insight-card-actions">
<span className={`insight-trigger-pill ${record.triggerReason}`}>{getTriggerLabel(record.triggerReason)}</span>
<span className="insight-time">{formatRecordTime(record.createdAt)}</span>
<button className="insight-action-btn" onClick={() => openChat(record)} title="打开聊天">
<MessageSquare size={14} />
</button>
<button className="insight-action-btn" onClick={() => { void copyText(record.insight, '见解已复制') }} title="复制见解">
<Copy size={14} />
</button>
<button className="insight-action-btn code" onClick={() => { void openLog(record.id) }} title="查看请求日志">
<Code size={14} />
</button>
</div>
</div>
<p className="insight-body">{record.insight}</p>
</div>
</article>
))}
</div>
))}
</div>
</section>
<aside className="insight-filter-panel">
<div className="insight-filter-header">
<h3></h3>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<Search size={14} />
<span></span>
</div>
<div className="insight-input-wrap">
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索见解或联系人..."
/>
{keyword && <button onClick={() => setKeyword('')}><X size={14} /></button>}
</div>
</div>
<div className="insight-filter-widget">
<div className="insight-widget-title">
<CalendarDays size={14} />
<span></span>
</div>
<div className="insight-date-tabs">
{[
{ value: 'all', label: '全部' },
{ value: 'today', label: '今天' },
{ value: 'week', label: '近 7 天' },
{ value: 'custom', label: '自定义' }
].map((option) => (
<button
key={option.value}
className={dateMode === option.value ? 'active' : ''}
onClick={() => setDateMode(option.value as DateFilterMode)}
>
{option.label}
</button>
))}
</div>
{dateMode === 'custom' && (
<div className="insight-custom-dates">
<input type="date" value={customStart} onChange={(event) => setCustomStart(event.target.value)} />
<input type="date" value={customEnd} onChange={(event) => setCustomEnd(event.target.value)} />
</div>
)}
</div>
<div className="insight-filter-widget contact-filter">
<div className="insight-widget-title">
<MessageSquare size={14} />
<span></span>
<span className="insight-widget-count">{contacts.length}</span>
</div>
<div className="insight-input-wrap">
<input
value={contactSearch}
onChange={(event) => setContactSearch(event.target.value)}
placeholder="查找联系人..."
/>
{contactSearch && <button onClick={() => setContactSearch('')}><X size={14} /></button>}
</div>
<button
className={`insight-contact-row all ${selectedSessionId ? '' : 'active'}`}
onClick={() => setSelectedSessionId('')}
>
<span></span>
<strong>{contacts.reduce((sum, contact) => sum + contact.count, 0)}</strong>
</button>
<div className="insight-contact-list">
{filteredContacts.map((contact) => (
<button
key={contact.sessionId}
className={`insight-contact-row ${selectedSessionId === contact.sessionId ? 'active' : ''}`}
onClick={() => setSelectedSessionId(contact.sessionId)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
<span>{contact.displayName}</span>
<strong>{contact.count}</strong>
</button>
))}
</div>
</div>
</aside>
{logRecord && (
<div className="insight-modal-overlay" onClick={() => setLogRecord(null)}>
<div className="insight-log-dialog" onClick={(event) => event.stopPropagation()}>
<div className="insight-log-header">
<div>
<h3></h3>
<span>{logRecord.displayName} · {formatRecordTime(logRecord.createdAt)}</span>
</div>
<div className="insight-log-actions">
<button onClick={() => { void copyText(buildLogText(logRecord), '请求日志已复制') }}>
<Copy size={15} />
</button>
<button className="close" onClick={() => setLogRecord(null)}>
<X size={18} />
</button>
</div>
</div>
<div className="insight-log-body">
<section>
<h4></h4>
<pre>{[
`Endpoint: ${logRecord.log.endpoint}`,
`Model: ${logRecord.log.model}`,
`Max Tokens: ${logRecord.log.maxTokens}`,
`Temperature: ${logRecord.log.temperature}`,
`Duration: ${logRecord.log.durationMs}ms`,
`Trigger: ${getTriggerLabel(logRecord.triggerReason)}`
].join('\n')}</pre>
</section>
<section>
<h4>System Prompt</h4>
<pre>{logRecord.log.systemPrompt}</pre>
</section>
<section>
<h4>User Prompt</h4>
<pre>{logRecord.log.userPrompt}</pre>
</section>
<section>
<h4></h4>
<pre>{logRecord.log.rawOutput}</pre>
</section>
<section>
<h4></h4>
<pre>{logRecord.log.finalInsight}</pre>
</section>
</div>
</div>
</div>
)}
{message && <div className="insight-copy-toast">{message}</div>}
</div>
)
}

View File

@@ -29,6 +29,9 @@ export default function NotificationWindow() {
const newNoti: NotificationData = { const newNoti: NotificationData = {
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`, id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
sessionId: data.sessionId, sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
title: data.title, title: data.title,
content: data.content, content: data.content,
timestamp: timestamp, timestamp: timestamp,
@@ -70,8 +73,17 @@ export default function NotificationWindow() {
window.electronAPI.notification?.close() window.electronAPI.notification?.close()
} }
const handleClick = (sessionId: string) => { const handleClick = (data: NotificationData) => {
window.electronAPI.notification?.click(sessionId) if (data.channel === 'ai-insight') {
window.electronAPI.notification?.click({
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute
})
} else {
window.electronAPI.notification?.click(data.sessionId)
}
setNotification(null) setNotification(null)
setPrevNotification(null) setPrevNotification(null)
// Main process handles window hide/close // Main process handles window hide/close

View File

@@ -199,6 +199,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh']) const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [notificationEnabled, setNotificationEnabled] = useState(true) const [notificationEnabled, setNotificationEnabled] = useState(true)
const [aiInsightNotificationEnabled, setAiInsightNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
@@ -326,7 +327,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null) const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState<string | null>(null)
const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false)
const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('')
const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false)
// 自动下载图片 // 自动下载图片
const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null) const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null)
@@ -458,6 +458,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAutoTranscribe = await configService.getAutoTranscribeVoice() const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages() const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedNotificationEnabled = await configService.getNotificationEnabled() const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedAiInsightNotificationEnabled = await configService.getAiInsightNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition() const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList() const savedNotificationFilterList = await configService.getNotificationFilterList()
@@ -512,6 +513,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setTranscribeLanguages(savedTranscribeLanguages) setTranscribeLanguages(savedTranscribeLanguages)
setNotificationEnabled(savedNotificationEnabled) setNotificationEnabled(savedNotificationEnabled)
setAiInsightNotificationEnabled(savedAiInsightNotificationEnabled)
setNotificationPosition(savedNotificationPosition) setNotificationPosition(savedNotificationPosition)
setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList) setNotificationFilterList(savedNotificationFilterList)
@@ -588,7 +590,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings() const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings()
const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() const savedAiFootprintEnabled = await configService.getAiFootprintEnabled()
const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt()
const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled()
setAiInsightEnabled(savedAiInsightEnabled) setAiInsightEnabled(savedAiInsightEnabled)
setAiModelApiBaseUrl(savedAiModelApiBaseUrl) setAiModelApiBaseUrl(savedAiModelApiBaseUrl)
@@ -615,7 +616,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setAiInsightWeiboBindings(savedAiInsightWeiboBindings) setAiInsightWeiboBindings(savedAiInsightWeiboBindings)
setAiFootprintEnabled(savedAiFootprintEnabled) setAiFootprintEnabled(savedAiFootprintEnabled)
setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt)
setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled)
} catch (e: any) { } catch (e: any) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
@@ -1903,6 +1903,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</div> </div>
<div className="form-group">
<label>AI </label>
<span className="form-hint"> AI Telegram </span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightNotificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="ai-insight-notification-enabled-toggle">
<input
id="ai-insight-notification-enabled-toggle"
className="switch-input"
type="checkbox"
checked={aiInsightNotificationEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightNotificationEnabled(val)
await configService.setAiInsightNotificationEnabled(val)
showMessage(val ? '已开启 AI 见解消息通知' : '已关闭 AI 见解消息通知', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
@@ -3209,7 +3232,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label>AI </label> <label>AI </label>
<span className="form-hint"> <span className="form-hint">
AI AI
</span> </span>
<div className="log-toggle-line"> <div className="log-toggle-line">
<span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span> <span className="log-status">{aiInsightEnabled ? '已开启' : '已关闭'}</span>
@@ -3923,32 +3946,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</div> </div>
<div className="divider" />
<div className="form-group">
<label></label>
<span className="form-hint">
AI <code>weflow-ai-insight-debug-YYYY-MM-DD.log</code>
AI API Key
</span>
<div className="log-toggle-line">
<span className="log-status">{aiInsightDebugLogEnabled ? '已开启' : '已关闭'}</span>
<label className="switch">
<input
type="checkbox"
checked={aiInsightDebugLogEnabled}
onChange={async (e) => {
const val = e.target.checked
setAiInsightDebugLogEnabled(val)
await configService.setAiInsightDebugLogEnabled(val)
showMessage(val ? '已开启 AI 见解调试日志,后续日志将写入桌面' : '已关闭 AI 见解调试日志', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div> </div>
) )

View File

@@ -66,6 +66,7 @@ export const CONFIG_KEYS = {
// 通知 // 通知
NOTIFICATION_ENABLED: 'notificationEnabled', NOTIFICATION_ENABLED: 'notificationEnabled',
AI_INSIGHT_NOTIFICATION_ENABLED: 'aiInsightNotificationEnabled',
NOTIFICATION_POSITION: 'notificationPosition', NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode', NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList', NOTIFICATION_FILTER_LIST: 'notificationFilterList',
@@ -1677,6 +1678,15 @@ export async function setNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled) await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled)
} }
export async function getAiInsightNotificationEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_NOTIFICATION_ENABLED)
return value !== false
}
export async function setAiInsightNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_NOTIFICATION_ENABLED, enabled)
}
// 获取通知位置 // 获取通知位置
export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> { export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION) const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION)

View File

@@ -21,6 +21,72 @@ export interface SocialSaveWeiboCookieResult {
error?: string error?: string
} }
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 InsightRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
}
export interface InsightRecord extends InsightRecordSummary {
accountScope: string
log: InsightRecordLog
}
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
}
export interface InsightRecordResult {
success: boolean
record?: InsightRecord
error?: string
}
export interface BackupProgress { export interface BackupProgress {
phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed' phase: 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
message: string message: string
@@ -167,13 +233,14 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
} }
notification: { notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void> show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => Promise<{ success?: boolean; error?: string } | void>
close: () => Promise<void> close: () => Promise<void>
click: (sessionId: string) => void click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
ready: () => void ready: () => void
resize: (width: number, height: number) => void resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void onNavigateToSession: (callback: (sessionId: string) => void) => () => void
onNavigateToRoute: (callback: (route: string) => void) => () => void
} }
log: { log: {
getPath: () => Promise<string> getPath: () => Promise<string>
@@ -1235,6 +1302,10 @@ export interface ElectronAPI {
insight: { insight: {
testConnection: () => Promise<{ success: boolean; message: string }> testConnection: () => Promise<{ success: boolean; message: string }>
getTodayStats: () => Promise<Array<{ sessionId: string; count: number; times: string[] }>> getTodayStats: () => Promise<Array<{ sessionId: string; count: number; times: string[] }>>
listRecords: (filters?: InsightRecordFilters) => Promise<InsightRecordListResult>
getRecord: (id: string) => Promise<InsightRecordResult>
markRecordRead: (id: string) => Promise<{ success: boolean; error?: string }>
clearRecords: (filters?: InsightRecordFilters) => Promise<{ success: boolean; removed: number; error?: string }>
triggerTest: () => Promise<{ success: boolean; message: string }> triggerTest: () => Promise<{ success: boolean; message: string }>
generateFootprintInsight: (payload: { generateFootprintInsight: (payload: {
rangeLabel: string rangeLabel: string