mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-06 07:26:48 +00:00
feat: add insight inbox
This commit is contained in:
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -572,6 +577,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
|
||||||
|
|||||||
292
electron/services/insightRecordService.ts
Normal file
292
electron/services/insightRecordService.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { createHash, randomUUID } from 'crypto'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||||
|
|
||||||
|
export interface InsightRecordLog {
|
||||||
|
endpoint: string
|
||||||
|
model: string
|
||||||
|
maxTokens: number
|
||||||
|
temperature: number
|
||||||
|
triggerReason: InsightRecordTriggerReason
|
||||||
|
allowContext: boolean
|
||||||
|
contextCount: number
|
||||||
|
systemPrompt: string
|
||||||
|
userPrompt: string
|
||||||
|
rawOutput: string
|
||||||
|
finalInsight: string
|
||||||
|
durationMs: number
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecord {
|
||||||
|
id: string
|
||||||
|
accountScope: string
|
||||||
|
createdAt: number
|
||||||
|
sessionId: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
triggerReason: InsightRecordTriggerReason
|
||||||
|
insight: string
|
||||||
|
read: boolean
|
||||||
|
log: InsightRecordLog
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecordSummary {
|
||||||
|
id: string
|
||||||
|
createdAt: number
|
||||||
|
sessionId: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
triggerReason: InsightRecordTriggerReason
|
||||||
|
insight: string
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecordContactFacet {
|
||||||
|
sessionId: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecordFilters {
|
||||||
|
keyword?: string
|
||||||
|
sessionId?: string
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecordListResult {
|
||||||
|
success: boolean
|
||||||
|
records: InsightRecordSummary[]
|
||||||
|
total: number
|
||||||
|
todayCount: number
|
||||||
|
unreadCount: number
|
||||||
|
contacts: InsightRecordContactFacet[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class InsightRecordService {
|
||||||
|
private readonly maxRecordsPerScope = 1000
|
||||||
|
private filePath: string | null = null
|
||||||
|
private loaded = false
|
||||||
|
private records: InsightRecord[] = []
|
||||||
|
|
||||||
|
private resolveFilePath(): string {
|
||||||
|
if (this.filePath) return this.filePath
|
||||||
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true })
|
||||||
|
this.filePath = path.join(userDataPath, 'weflow-insight-records.json')
|
||||||
|
return this.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureLoaded(): void {
|
||||||
|
if (this.loaded) return
|
||||||
|
this.loaded = true
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
|
||||||
|
} else if (Array.isArray(parsed?.records)) {
|
||||||
|
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.records = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
const filePath = this.resolveFilePath()
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
|
||||||
|
} catch {
|
||||||
|
// Keep insight generation non-blocking even if local persistence fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentAccountScope(): string {
|
||||||
|
const config = ConfigService.getInstance()
|
||||||
|
const myWxid = String(config.get('myWxid') || '').trim()
|
||||||
|
if (myWxid) return `wxid:${myWxid}`
|
||||||
|
|
||||||
|
const dbPath = String(config.get('dbPath') || '').trim()
|
||||||
|
if (dbPath) {
|
||||||
|
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
|
||||||
|
return `db:${hash}`
|
||||||
|
}
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStartOfToday(): number {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(record: InsightRecord): InsightRecordSummary {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
displayName: record.displayName,
|
||||||
|
avatarUrl: record.avatarUrl,
|
||||||
|
triggerReason: record.triggerReason,
|
||||||
|
insight: record.insight,
|
||||||
|
read: record.read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScopedRecords(): InsightRecord[] {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
return this.records.filter((record) => record.accountScope === scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecord(input: {
|
||||||
|
sessionId: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
triggerReason: InsightRecordTriggerReason
|
||||||
|
insight: string
|
||||||
|
log: InsightRecordLog
|
||||||
|
}): InsightRecord {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
const now = Date.now()
|
||||||
|
const record: InsightRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
accountScope: scope,
|
||||||
|
createdAt: now,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
avatarUrl: input.avatarUrl,
|
||||||
|
triggerReason: input.triggerReason,
|
||||||
|
insight: input.insight,
|
||||||
|
read: false,
|
||||||
|
log: input.log
|
||||||
|
}
|
||||||
|
|
||||||
|
this.records.push(record)
|
||||||
|
const scopedRecords = this.records
|
||||||
|
.filter((item) => item.accountScope === scope)
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
|
||||||
|
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
|
||||||
|
this.persist()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
|
||||||
|
try {
|
||||||
|
const allScoped = this.getScopedRecords()
|
||||||
|
const todayStart = this.getStartOfToday()
|
||||||
|
const contactsMap = new Map<string, InsightRecordContactFacet>()
|
||||||
|
for (const record of allScoped) {
|
||||||
|
const existing = contactsMap.get(record.sessionId)
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1
|
||||||
|
} else {
|
||||||
|
contactsMap.set(record.sessionId, {
|
||||||
|
sessionId: record.sessionId,
|
||||||
|
displayName: record.displayName,
|
||||||
|
avatarUrl: record.avatarUrl,
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||||
|
const sessionId = String(filters.sessionId || '').trim()
|
||||||
|
const startTime = Number(filters.startTime || 0)
|
||||||
|
const endTime = Number(filters.endTime || 0)
|
||||||
|
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||||
|
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
|
||||||
|
|
||||||
|
const filtered = allScoped
|
||||||
|
.filter((record) => {
|
||||||
|
if (sessionId && record.sessionId !== sessionId) return false
|
||||||
|
if (startTime > 0 && record.createdAt < startTime) return false
|
||||||
|
if (endTime > 0 && record.createdAt > endTime) return false
|
||||||
|
if (keyword) {
|
||||||
|
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
|
||||||
|
if (!haystack.includes(keyword)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
|
||||||
|
total: filtered.length,
|
||||||
|
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
|
||||||
|
unreadCount: allScoped.filter((record) => !record.read).length,
|
||||||
|
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
records: [],
|
||||||
|
total: 0,
|
||||||
|
todayCount: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
contacts: [],
|
||||||
|
error: (error as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const normalizedId = String(id || '').trim()
|
||||||
|
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||||
|
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||||
|
return { success: true, record }
|
||||||
|
}
|
||||||
|
|
||||||
|
markRecordRead(id: string): { success: boolean; error?: string } {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const normalizedId = String(id || '').trim()
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||||
|
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||||
|
if (!record.read) {
|
||||||
|
record.read = true
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
|
||||||
|
this.ensureLoaded()
|
||||||
|
const scope = this.getCurrentAccountScope()
|
||||||
|
const sessionId = String(filters.sessionId || '').trim()
|
||||||
|
const startTime = Number(filters.startTime || 0)
|
||||||
|
const endTime = Number(filters.endTime || 0)
|
||||||
|
let removed = 0
|
||||||
|
this.records = this.records.filter((record) => {
|
||||||
|
if (record.accountScope !== scope) return true
|
||||||
|
if (sessionId && record.sessionId !== sessionId) return true
|
||||||
|
if (startTime > 0 && record.createdAt < startTime) return true
|
||||||
|
if (endTime > 0 && record.createdAt > endTime) return true
|
||||||
|
removed += 1
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
this.persist()
|
||||||
|
return { success: true, removed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const insightRecordService = new InsightRecordService()
|
||||||
@@ -15,15 +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 } 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 { showNotification } from '../windows/notificationWindow'
|
||||||
|
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
|
||||||
|
|
||||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,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`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -518,7 +468,7 @@ class InsightService {
|
|||||||
await this.generateInsightForSession({
|
await this.generateInsightForSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
displayName,
|
displayName,
|
||||||
triggerReason: 'activity'
|
triggerReason: 'test'
|
||||||
})
|
})
|
||||||
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||||
return {
|
return {
|
||||||
@@ -1147,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
|
||||||
@@ -1158,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 ? '已配置' : '未配置'}`)
|
||||||
|
|
||||||
@@ -1236,6 +1193,7 @@ ${topMentionText}
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const apiStartedAt = Date.now()
|
||||||
const result = await callApi(
|
const result = await callApi(
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -1244,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)
|
||||||
@@ -1257,6 +1216,29 @@ ${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
|
||||||
|
})
|
||||||
|
|
||||||
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||||
if (insightNotificationEnabled) {
|
if (insightNotificationEnabled) {
|
||||||
@@ -1268,6 +1250,7 @@ ${topMentionText}
|
|||||||
content: insight,
|
content: insight,
|
||||||
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
|
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
insightRecordId: record.id,
|
||||||
channel: 'ai-insight'
|
channel: 'ai-insight'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -184,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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,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发出事件
|
||||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -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 />} />
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
612
src/pages/InsightInboxPage.scss
Normal file
612
src/pages/InsightInboxPage.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
470
src/pages/InsightInboxPage.tsx
Normal file
470
src/pages/InsightInboxPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -327,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)
|
||||||
@@ -591,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)
|
||||||
@@ -618,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)
|
||||||
@@ -3949,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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
75
src/types/electron.d.ts
vendored
75
src/types/electron.d.ts
vendored
@@ -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>
|
||||||
@@ -1234,6 +1301,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
|
||||||
|
|||||||
Reference in New Issue
Block a user