mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-08 23:16:44 +00:00
Compare commits
2 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a575bf58 | ||
|
|
606bc6ab66 |
@@ -166,15 +166,7 @@ async function run() {
|
|||||||
|
|
||||||
let result: any
|
let result: any
|
||||||
if (config.mode === 'contacts') {
|
if (config.mode === 'contacts') {
|
||||||
const [{ contactExportService }, { chatService }] = await Promise.all([
|
const { contactExportService } = await import('./services/contactExportService')
|
||||||
import('./services/contactExportService'),
|
|
||||||
import('./services/chatService')
|
|
||||||
])
|
|
||||||
chatService.setRuntimeConfig({
|
|
||||||
dbPath: config.dbPath,
|
|
||||||
decryptKey: config.decryptKey,
|
|
||||||
myWxid: config.myWxid
|
|
||||||
})
|
|
||||||
result = await contactExportService.exportContacts(
|
result = await contactExportService.exportContacts(
|
||||||
String(config.outputDir || ''),
|
String(config.outputDir || ''),
|
||||||
config.options || {}
|
config.options || {}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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'
|
||||||
@@ -735,41 +734,14 @@ 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, payload) => {
|
ipcMain.on('notification-clicked', (_event, sessionId) => {
|
||||||
handleNotificationClickNavigation(payload)
|
focusMainWindowAndNavigate(String(sessionId || ''))
|
||||||
})
|
})
|
||||||
setNotificationNavigateHandler((payload: unknown) => {
|
setNotificationNavigateHandler((sessionId: string) => {
|
||||||
handleNotificationClickNavigation(payload)
|
focusMainWindowAndNavigate(String(sessionId || ''))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1762,33 +1734,6 @@ 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()
|
||||||
})
|
})
|
||||||
@@ -2263,21 +2208,11 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
// WCDB 数据库相关
|
// WCDB 数据库相关
|
||||||
ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
|
ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
|
||||||
const cfg = configService || new ConfigService()
|
return wcdbService.testConnection(dbPath, hexKey, wxid)
|
||||||
const accountDir = cfg.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) {
|
|
||||||
return { success: false, error: '未找到账号目录' }
|
|
||||||
}
|
|
||||||
return wcdbService.testConnection(accountDir, hexKey)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
|
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
|
||||||
const cfg = configService || new ConfigService()
|
return wcdbService.open(dbPath, hexKey, wxid)
|
||||||
const accountDir = cfg.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return wcdbService.open(accountDir, hexKey)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('wcdb:close', async () => {
|
ipcMain.handle('wcdb:close', async () => {
|
||||||
@@ -2308,10 +2243,6 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getSessions()
|
return chatService.getSessions()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:markAllSessionsRead', async () => {
|
|
||||||
return chatService.markAllSessionsRead()
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
|
ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
|
||||||
return chatService.getSessionStatuses(usernames)
|
return chatService.getSessionStatuses(usernames)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: (payload: any) => ipcRenderer.send('notification-clicked', payload),
|
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
|
||||||
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,11 +24,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -190,7 +185,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
|
|
||||||
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
|
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
|
||||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||||
@@ -578,10 +572,6 @@ 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
|
||||||
|
|||||||
@@ -131,13 +131,9 @@ class AnalyticsService {
|
|||||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
|
||||||
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
return { success: true, cleanedWxid }
|
return { success: true, cleanedWxid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
|
||||||
|
|
||||||
export interface TopContact {
|
export interface TopContact {
|
||||||
username: string
|
username: string
|
||||||
@@ -159,14 +158,9 @@ class AnnualReportService {
|
|||||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const configService = ConfigService.getInstance()
|
|
||||||
const accountDir = configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
|
||||||
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -454,14 +454,14 @@ export class BackupService {
|
|||||||
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
|
if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
|
||||||
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
|
||||||
|
|
||||||
// 使用 ConfigService 统一解析账号目录
|
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
|
if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
|
||||||
const dbStorage = join(accountDir, 'db_storage')
|
const dbStorage = join(accountDir, 'db_storage')
|
||||||
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
|
||||||
|
|
||||||
|
const accountDirName = basename(accountDir)
|
||||||
const opened = await withTimeout(
|
const opened = await withTimeout(
|
||||||
wcdbService.open(accountDir, decryptKey),
|
wcdbService.open(dbPath, decryptKey, accountDirName),
|
||||||
15000,
|
15000,
|
||||||
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
'连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage'
|
|||||||
|
|
||||||
class ChatService {
|
class ChatService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private runtimeConfig?: { dbPath?: string; decryptKey?: string; myWxid?: string }
|
|
||||||
private connected = false
|
private connected = false
|
||||||
private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>()
|
private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>()
|
||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
|
||||||
@@ -453,10 +452,6 @@ class ChatService {
|
|||||||
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
||||||
}
|
}
|
||||||
|
|
||||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void {
|
|
||||||
this.runtimeConfig = config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理账号目录名
|
* 清理账号目录名
|
||||||
*/
|
*/
|
||||||
@@ -542,9 +537,12 @@ class ChatService {
|
|||||||
*/
|
*/
|
||||||
async connect(): Promise<{ success: boolean; error?: string }> {
|
async connect(): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
if (this.connected && wcdbService.isReady()) {
|
||||||
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
return { success: true }
|
||||||
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
}
|
||||||
|
const wxid = this.configService.get('myWxid')
|
||||||
|
const dbPath = this.configService.get('dbPath')
|
||||||
|
const decryptKey = this.configService.get('decryptKey')
|
||||||
if (!wxid) {
|
if (!wxid) {
|
||||||
return { success: false, error: '请先在设置页面配置微信ID' }
|
return { success: false, error: '请先在设置页面配置微信ID' }
|
||||||
}
|
}
|
||||||
@@ -555,17 +553,8 @@ class ChatService {
|
|||||||
return { success: false, error: '请先在设置页面配置解密密钥' }
|
return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.connected && wcdbService.isReady()) {
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
return { success: true }
|
const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 ConfigService 统一解析账号目录
|
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (!accountDir) {
|
|
||||||
return { success: false, error: '未找到账号目录,请检查数据库路径和微信ID配置' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const openOk = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!openOk) {
|
if (!openOk) {
|
||||||
const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
|
const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
|
||||||
await this.maybeShowInitFailureDialog(detailedError)
|
await this.maybeShowInitFailureDialog(detailedError)
|
||||||
@@ -978,23 +967,6 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
|
|
||||||
try {
|
|
||||||
const connectResult = await this.ensureConnected()
|
|
||||||
if (!connectResult.success) {
|
|
||||||
return { success: false, error: connectResult.error }
|
|
||||||
}
|
|
||||||
const result = await wcdbService.markAllSessionsRead()
|
|
||||||
if (result.success) {
|
|
||||||
this.syntheticUnreadState.clear()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
} catch (e) {
|
|
||||||
console.error('ChatService: 一键已读失败:', e)
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSessionUsername(row: Record<string, any>): string {
|
private getSessionUsername(row: Record<string, any>): string {
|
||||||
return String(
|
return String(
|
||||||
row.username ||
|
row.username ||
|
||||||
@@ -2530,7 +2502,7 @@ class ChatService {
|
|||||||
const rawRows = result.messages as Record<string, any>[]
|
const rawRows = result.messages as Record<string, any>[]
|
||||||
const hasMore = rawRows.length > pageLimit
|
const hasMore = rawRows.length > pageLimit
|
||||||
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
|
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
|
||||||
const mapped = this.mapRowsToMessages(selectedRows, sessionId)
|
const mapped = this.mapRowsToMessages(selectedRows)
|
||||||
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
|
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
|
||||||
const outputMessages = (visible.length === 0 && mapped.length > 0)
|
const outputMessages = (visible.length === 0 && mapped.length > 0)
|
||||||
? mapped
|
? mapped
|
||||||
@@ -2541,7 +2513,6 @@ class ChatService {
|
|||||||
const normalized = this.normalizeMessageOrder(outputMessages)
|
const normalized = this.normalizeMessageOrder(outputMessages)
|
||||||
if (normalized.length > 0) {
|
if (normalized.length > 0) {
|
||||||
await this.repairEmojiMessages(normalized)
|
await this.repairEmojiMessages(normalized)
|
||||||
await this.resolveQuotedMessages(normalized, sessionId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2598,7 +2569,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换为 Message 对象
|
// 转换为 Message 对象
|
||||||
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[], sessionId)
|
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[])
|
||||||
const normalized = this.normalizeMessageOrder(messages)
|
const normalized = this.normalizeMessageOrder(messages)
|
||||||
|
|
||||||
// 并发检查并修复缺失 CDN URL 的表情包
|
// 并发检查并修复缺失 CDN URL 的表情包
|
||||||
@@ -2835,7 +2806,7 @@ class ChatService {
|
|||||||
|
|
||||||
const rowsToProcess = queuedRows
|
const rowsToProcess = queuedRows
|
||||||
queuedRows = []
|
queuedRows = []
|
||||||
const mappedMessages = this.mapRowsToMessages(rowsToProcess, sessionId)
|
const mappedMessages = this.mapRowsToMessages(rowsToProcess)
|
||||||
for (let index = 0; index < mappedMessages.length; index += 1) {
|
for (let index = 0; index < mappedMessages.length; index += 1) {
|
||||||
const msg = mappedMessages[index]
|
const msg = mappedMessages[index]
|
||||||
rawRowsConsumed += 1
|
rawRowsConsumed += 1
|
||||||
@@ -4826,8 +4797,8 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
|
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
|
||||||
*/
|
*/
|
||||||
mapRowsToMessagesForApi(rows: Record<string, any>[], sessionId: string): Message[] {
|
mapRowsToMessagesForApi(rows: Record<string, any>[]): Message[] {
|
||||||
return this.mapRowsToMessages(rows, sessionId)
|
return this.mapRowsToMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
mapRowsToMessagesLiteForApi(rows: Record<string, any>[]): Message[] {
|
mapRowsToMessagesLiteForApi(rows: Record<string, any>[]): Message[] {
|
||||||
@@ -4881,7 +4852,7 @@ class ChatService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapRowsToMessages(rows: Record<string, any>[], sessionId: string): Message[] {
|
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||||
const myWxid = this.configService.get('myWxid')
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
|
||||||
const messages: Message[] = []
|
const messages: Message[] = []
|
||||||
@@ -5001,23 +4972,11 @@ class ChatService {
|
|||||||
encrypVer = imageInfo.encrypVer
|
encrypVer = imageInfo.encrypVer
|
||||||
cdnThumbUrl = imageInfo.cdnThumbUrl
|
cdnThumbUrl = imageInfo.cdnThumbUrl
|
||||||
imageDatName = this.parseImageDatNameFromRow(row)
|
imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
// 解析图片消息中的引用信息
|
|
||||||
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
|
|
||||||
if (quoteInfo.content) quotedContent = quoteInfo.content
|
|
||||||
if (quoteInfo.sender) quotedSender = quoteInfo.sender
|
|
||||||
} else if (localType === 43) {
|
} else if (localType === 43) {
|
||||||
// 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML
|
// 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML
|
||||||
videoMd5 = this.parseVideoFileNameFromRow(row, content)
|
videoMd5 = this.parseVideoFileNameFromRow(row, content)
|
||||||
// 解析视频消息中的引用信息
|
|
||||||
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
|
|
||||||
if (quoteInfo.content) quotedContent = quoteInfo.content
|
|
||||||
if (quoteInfo.sender) quotedSender = quoteInfo.sender
|
|
||||||
} else if (localType === 34 && content) {
|
} else if (localType === 34 && content) {
|
||||||
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
||||||
// 解析语音消息中的引用信息
|
|
||||||
const quoteInfo = this.parseMediaQuoteMessage(content, sessionId)
|
|
||||||
if (quoteInfo.content) quotedContent = quoteInfo.content
|
|
||||||
if (quoteInfo.sender) quotedSender = quoteInfo.sender
|
|
||||||
} else if (localType === 42 && content) {
|
} else if (localType === 42 && content) {
|
||||||
// 名片消息
|
// 名片消息
|
||||||
const cardInfo = this.parseCardInfo(content)
|
const cardInfo = this.parseCardInfo(content)
|
||||||
@@ -5738,18 +5697,9 @@ class ChatService {
|
|||||||
case '47':
|
case '47':
|
||||||
displayContent = '[动画表情]'
|
displayContent = '[动画表情]'
|
||||||
break
|
break
|
||||||
case '49': {
|
case '49':
|
||||||
// 链接类消息 (type=49):需区分真正的链接和嵌套引用
|
displayContent = '[链接]'
|
||||||
// 嵌套引用的 referContent 中 xmlType=57,真正的链接 xmlType=49 或 5
|
|
||||||
const decodedReferContent = this.decodeHtmlEntities(referContent || '')
|
|
||||||
const innerInfo = this.parseType49Message(decodedReferContent)
|
|
||||||
if (innerInfo.xmlType === '57' && innerInfo.linkTitle) {
|
|
||||||
displayContent = innerInfo.linkTitle
|
|
||||||
} else {
|
|
||||||
displayContent = '[链接]'
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
|
||||||
case '42':
|
case '42':
|
||||||
displayContent = '[名片]'
|
displayContent = '[名片]'
|
||||||
break
|
break
|
||||||
@@ -5773,116 +5723,6 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析媒体消息(图片/视频/语音)中的引用信息
|
|
||||||
* 这些消息的引用信息在 <extcommoninfo><refermsg> 中
|
|
||||||
*/
|
|
||||||
private parseMediaQuoteMessage(content: string, sessionId: string): { content?: string; sender?: string } {
|
|
||||||
try {
|
|
||||||
const normalizedContent = this.decodeHtmlEntities(content || '')
|
|
||||||
const referMsgStart = normalizedContent.indexOf('<refermsg>')
|
|
||||||
const referMsgEnd = normalizedContent.indexOf('</refermsg>')
|
|
||||||
|
|
||||||
if (referMsgStart === -1 || referMsgEnd === -1) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11)
|
|
||||||
const svrid = this.extractXmlValue(referMsgXml, 'svrid')
|
|
||||||
|
|
||||||
console.log('[DEBUG] parseMediaQuoteMessage - svrid:', svrid)
|
|
||||||
|
|
||||||
if (!svrid) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 简化方案:返回 svrid 标记
|
|
||||||
console.log('[DEBUG] parseMediaQuoteMessage - 返回标记:', `__SVRID__${svrid}__`)
|
|
||||||
return { content: `__SVRID__${svrid}__` }
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveQuotedMessages(messages: Message[], sessionId: string): Promise<void> {
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 开始解析,消息数量:', messages.length)
|
|
||||||
const svridsToResolve: Array<{ msg: Message; svrid: string }> = []
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.quotedContent && msg.quotedContent.startsWith('__SVRID__')) {
|
|
||||||
const match = msg.quotedContent.match(/__SVRID__(.+?)__/)
|
|
||||||
if (match) {
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 找到需要解析的svrid:', match[1])
|
|
||||||
svridsToResolve.push({ msg, svrid: match[1] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 需要解析的数量:', svridsToResolve.length)
|
|
||||||
|
|
||||||
if (svridsToResolve.length === 0) return
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
svridsToResolve.map(({ svrid }) => {
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 查询svrid:', svrid, 'sessionId:', sessionId)
|
|
||||||
return wcdbService.getMessageByServerId(sessionId, svrid)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 查询结果数量:', results.length)
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
const result = results[i]
|
|
||||||
const { msg, svrid } = svridsToResolve[i]
|
|
||||||
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 处理结果', i, ':', {
|
|
||||||
status: result.status,
|
|
||||||
success: result.status === 'fulfilled' ? result.value.success : false,
|
|
||||||
hasRow: result.status === 'fulfilled' && result.value.row ? true : false,
|
|
||||||
error: result.status === 'fulfilled' ? result.value.error : undefined,
|
|
||||||
svrid
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.status === 'fulfilled' && result.value.success && result.value.row) {
|
|
||||||
const localType = parseInt(result.value.row.local_type || '0', 10)
|
|
||||||
const rawMessageContent = result.value.row.message_content
|
|
||||||
const rawCompressContent = result.value.row.compress_content
|
|
||||||
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 原始数据:', {
|
|
||||||
hasMessageContent: !!rawMessageContent,
|
|
||||||
hasCompressContent: !!rawCompressContent,
|
|
||||||
messageContentType: typeof rawMessageContent,
|
|
||||||
messageContentLength: rawMessageContent ? rawMessageContent.length : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
|
||||||
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 解码后:', { localType, contentLength: content.length, contentPreview: content.substring(0, 50) })
|
|
||||||
|
|
||||||
if (localType === 1) {
|
|
||||||
msg.quotedContent = this.sanitizeQuotedContent(content)
|
|
||||||
} else if (localType === 3) {
|
|
||||||
msg.quotedContent = '[图片]'
|
|
||||||
} else if (localType === 34) {
|
|
||||||
msg.quotedContent = '[语音]'
|
|
||||||
} else if (localType === 43) {
|
|
||||||
msg.quotedContent = '[视频]'
|
|
||||||
} else if (localType === 47) {
|
|
||||||
msg.quotedContent = '[动画表情]'
|
|
||||||
} else if (localType === 49) {
|
|
||||||
msg.quotedContent = '[链接]'
|
|
||||||
} else {
|
|
||||||
msg.quotedContent = '[消息]'
|
|
||||||
}
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 更新后的quotedContent:', msg.quotedContent)
|
|
||||||
} else {
|
|
||||||
msg.quotedContent = '[引用消息]'
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 查询失败,使用占位符')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('[DEBUG] resolveQuotedMessages - 完成')
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractPreferredQuotedText(referMsgXml: string): string {
|
private extractPreferredQuotedText(referMsgXml: string): string {
|
||||||
if (!referMsgXml) return ''
|
if (!referMsgXml) return ''
|
||||||
|
|
||||||
@@ -6812,35 +6652,17 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cleanSystemMessage(content: string): string {
|
private cleanSystemMessage(content: string): string {
|
||||||
if (!content) return '[系统消息]'
|
|
||||||
|
|
||||||
const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content)))
|
|
||||||
const readableSysmsg = this.extractReadableSystemMessageText(normalized)
|
|
||||||
if (readableSysmsg) {
|
|
||||||
return readableSysmsg
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除 XML 声明
|
// 移除 XML 声明
|
||||||
let cleaned = normalized.replace(/<\?xml[^?]*\?>/gi, '')
|
let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '')
|
||||||
// 移除所有 XML/HTML 标签
|
// 移除所有 XML/HTML 标签
|
||||||
cleaned = cleaned.replace(/<[^>]+>/g, '')
|
cleaned = cleaned.replace(/<[^>]+>/g, '')
|
||||||
// 移除尾部的数字(如撤回消息后的时间戳)
|
// 移除尾部的数字(如撤回消息后的时间戳)
|
||||||
cleaned = cleaned.replace(/\d+\s*$/, '')
|
cleaned = cleaned.replace(/\d+\s*$/, '')
|
||||||
// 清理多余空白
|
// 清理多余空白
|
||||||
cleaned = this.stripSenderPrefix(cleaned).replace(/\s+/g, ' ').trim()
|
cleaned = cleaned.replace(/\s+/g, ' ').trim()
|
||||||
return cleaned || '[系统消息]'
|
return cleaned || '[系统消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractReadableSystemMessageText(content: string): string {
|
|
||||||
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(content)
|
|
||||||
const source = sysmsgMatch?.[1] || content
|
|
||||||
const text =
|
|
||||||
this.extractXmlValue(source, 'plain') ||
|
|
||||||
this.extractXmlValue(source, 'text') ||
|
|
||||||
''
|
|
||||||
return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
private stripSenderPrefix(content: string): string {
|
private stripSenderPrefix(content: string): string {
|
||||||
return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '')
|
return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '')
|
||||||
}
|
}
|
||||||
@@ -8915,7 +8737,7 @@ class ChatService {
|
|||||||
return { success: false, error: result.error || '查询语音消息失败' }
|
return { success: false, error: result.error || '查询语音消息失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
|
let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||||
|
|
||||||
// 按 createTime 降序排序
|
// 按 createTime 降序排序
|
||||||
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
||||||
@@ -8958,7 +8780,7 @@ class ChatService {
|
|||||||
return { success: false, error: result.error || '查询图片消息失败' }
|
return { success: false, error: result.error || '查询图片消息失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
|
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||||
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped
|
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped
|
||||||
.filter(msg => msg.localType === 3)
|
.filter(msg => msg.localType === 3)
|
||||||
.map(msg => ({
|
.map(msg => ({
|
||||||
@@ -9083,7 +8905,7 @@ class ChatService {
|
|||||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue
|
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) continue
|
||||||
if (result.rows.length >= perTypeFetch) maybeHasMore = true
|
if (result.rows.length >= perTypeFetch) maybeHasMore = true
|
||||||
|
|
||||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[], sessionId)
|
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||||
for (const message of mapped) {
|
for (const message of mapped) {
|
||||||
const resourceType = this.resolveResourceType(message)
|
const resourceType = this.resolveResourceType(message)
|
||||||
if (!resourceType || !typeSet.has(resourceType)) continue
|
if (!resourceType || !typeSet.has(resourceType)) continue
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
|
||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
@@ -59,7 +58,6 @@ 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[]
|
||||||
@@ -147,9 +145,6 @@ export class ConfigService {
|
|||||||
private unlockedKeys: Map<string, any> = new Map()
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
private unlockPassword: string | null = null
|
private unlockPassword: string | null = null
|
||||||
|
|
||||||
// 账号目录缓存
|
|
||||||
private accountDirCache: Map<string, string> = new Map()
|
|
||||||
|
|
||||||
static getInstance(): ConfigService {
|
static getInstance(): ConfigService {
|
||||||
if (!ConfigService.instance) {
|
if (!ConfigService.instance) {
|
||||||
ConfigService.instance = new ConfigService()
|
ConfigService.instance = new ConfigService()
|
||||||
@@ -193,7 +188,6 @@ 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: [],
|
||||||
@@ -845,99 +839,6 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理账号目录名称(移除后缀)
|
|
||||||
*/
|
|
||||||
private cleanAccountDirName(dirName: string): string {
|
|
||||||
const trimmed = dirName.trim()
|
|
||||||
if (!trimmed) return trimmed
|
|
||||||
|
|
||||||
// wxid_ 开头的特殊处理
|
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
|
||||||
if (match) return match[1]
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除4位后缀
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
|
||||||
if (suffixMatch) return suffixMatch[1]
|
|
||||||
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否是目录
|
|
||||||
*/
|
|
||||||
private isDirectory(path: string): boolean {
|
|
||||||
try {
|
|
||||||
return statSync(path).isDirectory()
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取账号目录路径
|
|
||||||
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
|
|
||||||
*
|
|
||||||
* @param dbPath 数据库根目录(可选,默认从配置读取)
|
|
||||||
* @param wxid 微信ID(可选,默认从配置读取)
|
|
||||||
* @returns 账号目录的完整路径,如果找不到返回 null
|
|
||||||
*/
|
|
||||||
getAccountDir(dbPath?: string, wxid?: string): string | null {
|
|
||||||
const actualDbPath = dbPath || this.get('dbPath')
|
|
||||||
const actualWxid = wxid || this.get('myWxid')
|
|
||||||
|
|
||||||
if (!actualDbPath || !actualWxid) return null
|
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(actualWxid)
|
|
||||||
const normalized = actualDbPath.replace(/[\\/]+$/, '')
|
|
||||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
const cached = this.accountDirCache.get(cacheKey)
|
|
||||||
if (cached && existsSync(cached)) return cached
|
|
||||||
if (cached && !existsSync(cached)) {
|
|
||||||
this.accountDirCache.delete(cacheKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试直接路径(非 wxid_ 开头的账号)
|
|
||||||
const lowerWxid = cleanedWxid.toLowerCase()
|
|
||||||
if (!lowerWxid.startsWith('wxid_')) {
|
|
||||||
const direct = join(normalized, cleanedWxid)
|
|
||||||
if (existsSync(direct) && this.isDirectory(direct)) {
|
|
||||||
this.accountDirCache.set(cacheKey, direct)
|
|
||||||
return direct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描目录查找匹配的账号目录
|
|
||||||
try {
|
|
||||||
const entries = readdirSync(normalized)
|
|
||||||
for (const entry of entries) {
|
|
||||||
const entryPath = join(normalized, entry)
|
|
||||||
if (!this.isDirectory(entryPath)) continue
|
|
||||||
|
|
||||||
const lowerEntry = entry.toLowerCase()
|
|
||||||
const isExactMatch = lowerEntry === lowerWxid
|
|
||||||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
|
||||||
|
|
||||||
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
|
|
||||||
const shouldMatch = lowerWxid.startsWith('wxid_')
|
|
||||||
? isSuffixMatch
|
|
||||||
: (isExactMatch || isSuffixMatch)
|
|
||||||
|
|
||||||
if (shouldMatch) {
|
|
||||||
this.accountDirCache.set(cacheKey, entryPath)
|
|
||||||
return entryPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private getUserDataPath(): string {
|
private getUserDataPath(): string {
|
||||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
if (workerUserDataPath) {
|
if (workerUserDataPath) {
|
||||||
|
|||||||
@@ -160,16 +160,6 @@ export class DbPathService {
|
|||||||
|
|
||||||
// 检查是否有有效账号目录结构
|
// 检查是否有有效账号目录结构
|
||||||
if (this.isAccountDir(entryPath)) {
|
if (this.isAccountDir(entryPath)) {
|
||||||
// 过滤掉不带后缀的 wxid_ 目录
|
|
||||||
const lowerEntry = entry.toLowerCase()
|
|
||||||
if (lowerEntry.startsWith('wxid_')) {
|
|
||||||
// wxid_ 开头的目录必须带后缀(wxid_xxx_yyyy 格式)
|
|
||||||
const parts = entry.split('_')
|
|
||||||
if (parts.length <= 2) {
|
|
||||||
// wxid_xxx 格式,跳过
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accounts.push(entry)
|
accounts.push(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,16 +232,6 @@ export class DbPathService {
|
|||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (lower === 'all_users') continue
|
if (lower === 'all_users') continue
|
||||||
if (!entry.includes('_')) continue
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
// 过滤掉不带后缀的 wxid_ 目录
|
|
||||||
if (lower.startsWith('wxid_')) {
|
|
||||||
const parts = entry.split('_')
|
|
||||||
if (parts.length <= 2) {
|
|
||||||
// wxid_xxx 格式,跳过
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ class DualReportService {
|
|||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1889,9 +1889,7 @@ class ExportService {
|
|||||||
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
return { success: true, cleanedWxid }
|
return { success: true, cleanedWxid }
|
||||||
}
|
}
|
||||||
@@ -2180,10 +2178,6 @@ class ExportService {
|
|||||||
*/
|
*/
|
||||||
private convertMessageType(localType: number, content: string): number {
|
private convertMessageType(localType: number, content: string): number {
|
||||||
const normalized = this.normalizeAppMessageContent(content || '')
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
if (this.isReadableSystemMessage(localType, normalized)) {
|
|
||||||
return 80
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlTypeRaw = this.extractAppMessageType(normalized)
|
const xmlTypeRaw = this.extractAppMessageType(normalized)
|
||||||
const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
|
const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
|
||||||
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||||
@@ -2207,12 +2201,6 @@ class ExportService {
|
|||||||
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
|
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReadableSystemMessage(localType: number, content: string): boolean {
|
|
||||||
if (localType === 10000) return true
|
|
||||||
const normalized = this.normalizeAppMessageContent(content || '')
|
|
||||||
return /<sysmsg\b/i.test(this.stripSenderPrefix(normalized))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解码消息内容
|
* 解码消息内容
|
||||||
*/
|
*/
|
||||||
@@ -2639,10 +2627,6 @@ class ExportService {
|
|||||||
emojiCaption?: string
|
emojiCaption?: string
|
||||||
): string {
|
): string {
|
||||||
const safeContent = content || ''
|
const safeContent = content || ''
|
||||||
const readableSystemText = this.extractReadableSystemMessageText(safeContent)
|
|
||||||
if (readableSystemText && this.isReadableSystemMessage(localType, safeContent)) {
|
|
||||||
return readableSystemText
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localType === 3) return '[图片]'
|
if (localType === 3) return '[图片]'
|
||||||
if (localType === 1) return this.stripSenderPrefix(safeContent)
|
if (localType === 1) return this.stripSenderPrefix(safeContent)
|
||||||
@@ -3091,18 +3075,6 @@ class ExportService {
|
|||||||
.trim() || '[系统消息]'
|
.trim() || '[系统消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractReadableSystemMessageText(content: string): string {
|
|
||||||
if (!content) return ''
|
|
||||||
const normalized = this.normalizeAppMessageContent(content)
|
|
||||||
const sysmsgMatch = /<sysmsg\b[^>]*>([\s\S]*?)<\/sysmsg>/i.exec(this.stripSenderPrefix(normalized))
|
|
||||||
const source = sysmsgMatch?.[1] || normalized
|
|
||||||
const text =
|
|
||||||
this.extractXmlValue(source, 'plain') ||
|
|
||||||
this.extractXmlValue(source, 'text') ||
|
|
||||||
''
|
|
||||||
return this.stripSenderPrefix(text).replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析通话消息
|
* 解析通话消息
|
||||||
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
||||||
@@ -3167,9 +3139,6 @@ class ExportService {
|
|||||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||||
if (content) {
|
if (content) {
|
||||||
const normalized = this.normalizeAppMessageContent(content)
|
const normalized = this.normalizeAppMessageContent(content)
|
||||||
if (this.isReadableSystemMessage(localType, normalized)) {
|
|
||||||
return '系统消息'
|
|
||||||
}
|
|
||||||
const xmlType = this.extractAppMessageType(normalized)
|
const xmlType = this.extractAppMessageType(normalized)
|
||||||
|
|
||||||
if (xmlType) {
|
if (xmlType) {
|
||||||
@@ -3536,49 +3505,7 @@ class ExportService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveQuotedMessagesForExport(messages: any[], sessionId: string): Promise<void> {
|
private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||||
const svridsToResolve: Array<{ msg: any; svrid: string }> = []
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.replyToMessageId && msg.quotedContent === '[消息]') {
|
|
||||||
svridsToResolve.push({ msg, svrid: msg.replyToMessageId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (svridsToResolve.length === 0) return
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
svridsToResolve.map(({ svrid }) => wcdbService.getMessageByServerId(sessionId, svrid))
|
|
||||||
)
|
|
||||||
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
const result = results[i]
|
|
||||||
const { msg } = svridsToResolve[i]
|
|
||||||
|
|
||||||
if (result.status === 'fulfilled' && result.value.success && result.value.row) {
|
|
||||||
const localType = parseInt(result.value.row.local_type || '0', 10)
|
|
||||||
const rawMessageContent = result.value.row.message_content
|
|
||||||
const rawCompressContent = result.value.row.compress_content
|
|
||||||
const content = chatService['decodeMessageContent'](rawMessageContent, rawCompressContent)
|
|
||||||
|
|
||||||
if (localType === 1) {
|
|
||||||
msg.quotedContent = chatService['sanitizeQuotedContent'](content)
|
|
||||||
} else if (localType === 3) {
|
|
||||||
msg.quotedContent = '[图片]'
|
|
||||||
} else if (localType === 34) {
|
|
||||||
msg.quotedContent = '[语音]'
|
|
||||||
} else if (localType === 43) {
|
|
||||||
msg.quotedContent = '[视频]'
|
|
||||||
} else if (localType === 47) {
|
|
||||||
msg.quotedContent = '[动画表情]'
|
|
||||||
} else if (localType === 49) {
|
|
||||||
msg.quotedContent = '[链接]'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string; svrid?: string } {
|
|
||||||
try {
|
try {
|
||||||
const normalized = this.normalizeAppMessageContent(content || '')
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
const referMsgStart = normalized.indexOf('<refermsg>')
|
const referMsgStart = normalized.indexOf('<refermsg>')
|
||||||
@@ -3595,7 +3522,6 @@ class ExportService {
|
|||||||
|
|
||||||
const referContent = this.extractXmlValue(referMsgXml, 'content')
|
const referContent = this.extractXmlValue(referMsgXml, 'content')
|
||||||
const referType = this.extractXmlValue(referMsgXml, 'type')
|
const referType = this.extractXmlValue(referMsgXml, 'type')
|
||||||
const svrid = this.extractXmlValue(referMsgXml, 'svrid')
|
|
||||||
let displayContent = referContent
|
let displayContent = referContent
|
||||||
|
|
||||||
switch (referType) {
|
switch (referType) {
|
||||||
@@ -3818,7 +3744,6 @@ class ExportService {
|
|||||||
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
|
if (quoteInfo.content) meta.quotedContent = quoteInfo.content
|
||||||
if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender
|
if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender
|
||||||
if (quoteInfo.type) meta.quotedType = quoteInfo.type
|
if (quoteInfo.type) meta.quotedType = quoteInfo.type
|
||||||
if (quoteInfo.svrid) meta.quotedSvrid = quoteInfo.svrid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appMsgKind === 'link') {
|
if (appMsgKind === 'link') {
|
||||||
@@ -4011,11 +3936,6 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
const readableSystemText = this.extractReadableSystemMessageText(content)
|
|
||||||
if (readableSystemText && this.isReadableSystemMessage(localType, content)) {
|
|
||||||
return readableSystemText
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localType === 1) {
|
if (localType === 1) {
|
||||||
return this.stripSenderPrefix(content)
|
return this.stripSenderPrefix(content)
|
||||||
}
|
}
|
||||||
@@ -6665,9 +6585,6 @@ class ExportService {
|
|||||||
msg.emojiCaption
|
msg.emojiCaption
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (this.isReadableSystemMessage(msg.localType, msg.content)) {
|
|
||||||
content = this.extractReadableSystemMessageText(msg.content) || content
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
if (content && this.isTransferExportContent(content) && msg.content) {
|
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||||
@@ -6979,9 +6896,6 @@ class ExportService {
|
|||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
|
|
||||||
// 解析引用消息
|
|
||||||
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
|
|
||||||
|
|
||||||
const voiceMessages = options.exportVoiceAsText
|
const voiceMessages = options.exportVoiceAsText
|
||||||
? collected.rows.filter(msg => msg.localType === 34)
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
: []
|
: []
|
||||||
@@ -7172,9 +7086,6 @@ class ExportService {
|
|||||||
msg.emojiCaption
|
msg.emojiCaption
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (this.isReadableSystemMessage(msg.localType, msg.content)) {
|
|
||||||
content = this.extractReadableSystemMessageText(msg.content) || content
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
@@ -7186,8 +7097,7 @@ class ExportService {
|
|||||||
rawMyWxid,
|
rawMyWxid,
|
||||||
myDisplayName: myInfo.displayName || cleanedMyWxid
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
})
|
})
|
||||||
// 对于媒体消息,不要让引用信息覆盖媒体路径
|
if (quotedReplyDisplay) {
|
||||||
if (quotedReplyDisplay && !mediaItem) {
|
|
||||||
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7231,7 +7141,7 @@ class ExportService {
|
|||||||
localId: allMessages.length + 1,
|
localId: allMessages.length + 1,
|
||||||
createTime: msg.createTime,
|
createTime: msg.createTime,
|
||||||
formattedTime: this.formatTimestamp(msg.createTime),
|
formattedTime: this.formatTimestamp(msg.createTime),
|
||||||
type: this.getMessageTypeName(msg.localType, msg.content),
|
type: this.getMessageTypeName(msg.localType),
|
||||||
localType: msg.localType,
|
localType: msg.localType,
|
||||||
content,
|
content,
|
||||||
isSend: msg.isSend ? 1 : 0,
|
isSend: msg.isSend ? 1 : 0,
|
||||||
@@ -7722,9 +7632,6 @@ class ExportService {
|
|||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
|
|
||||||
// 解析引用消息
|
|
||||||
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
|
|
||||||
|
|
||||||
const voiceMessages = options.exportVoiceAsText
|
const voiceMessages = options.exportVoiceAsText
|
||||||
? collected.rows.filter(msg => msg.localType === 34)
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
: []
|
: []
|
||||||
@@ -8145,20 +8052,20 @@ class ExportService {
|
|||||||
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||||
if (useCompactColumns) {
|
if (useCompactColumns) {
|
||||||
worksheet.getCell(currentRow, 3).value = senderRole
|
worksheet.getCell(currentRow, 3).value = senderRole
|
||||||
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType, msg.content)
|
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
||||||
} else if (includeGroupNicknameColumn) {
|
} else if (includeGroupNicknameColumn) {
|
||||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||||
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
||||||
worksheet.getCell(currentRow, 7).value = senderRole
|
worksheet.getCell(currentRow, 7).value = senderRole
|
||||||
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType, msg.content)
|
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
||||||
} else {
|
} else {
|
||||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||||
worksheet.getCell(currentRow, 6).value = senderRole
|
worksheet.getCell(currentRow, 6).value = senderRole
|
||||||
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType, msg.content)
|
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||||
}
|
}
|
||||||
contentCell.value = enrichedContentValue
|
contentCell.value = enrichedContentValue
|
||||||
if (!quotedReplyDisplay) {
|
if (!quotedReplyDisplay) {
|
||||||
@@ -8431,7 +8338,7 @@ class ExportService {
|
|||||||
i + 1,
|
i + 1,
|
||||||
this.formatTimestamp(msg.createTime),
|
this.formatTimestamp(msg.createTime),
|
||||||
senderRole,
|
senderRole,
|
||||||
this.getMessageTypeName(msg.localType, msg.content),
|
this.getMessageTypeName(msg.localType),
|
||||||
enrichedContentValue
|
enrichedContentValue
|
||||||
]
|
]
|
||||||
: includeGroupNicknameColumn
|
: includeGroupNicknameColumn
|
||||||
@@ -8443,7 +8350,7 @@ class ExportService {
|
|||||||
senderRemark,
|
senderRemark,
|
||||||
senderGroupNickname,
|
senderGroupNickname,
|
||||||
senderRole,
|
senderRole,
|
||||||
this.getMessageTypeName(msg.localType, msg.content),
|
this.getMessageTypeName(msg.localType),
|
||||||
enrichedContentValue
|
enrichedContentValue
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -8453,7 +8360,7 @@ class ExportService {
|
|||||||
senderWxid,
|
senderWxid,
|
||||||
senderRemark,
|
senderRemark,
|
||||||
senderRole,
|
senderRole,
|
||||||
this.getMessageTypeName(msg.localType, msg.content),
|
this.getMessageTypeName(msg.localType),
|
||||||
enrichedContentValue
|
enrichedContentValue
|
||||||
])
|
])
|
||||||
if (!quotedReplyDisplay) {
|
if (!quotedReplyDisplay) {
|
||||||
@@ -8603,9 +8510,6 @@ class ExportService {
|
|||||||
|
|
||||||
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control)
|
||||||
|
|
||||||
// 解析引用消息
|
|
||||||
await this.resolveQuotedMessagesForExport(collected.rows, sessionId)
|
|
||||||
|
|
||||||
const voiceMessages = options.exportVoiceAsText
|
const voiceMessages = options.exportVoiceAsText
|
||||||
? collected.rows.filter(msg => msg.localType === 34)
|
? collected.rows.filter(msg => msg.localType === 34)
|
||||||
: []
|
: []
|
||||||
@@ -9731,7 +9635,7 @@ class ExportService {
|
|||||||
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
|
const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName)
|
||||||
|
|
||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
const typeName = this.getMessageTypeName(msg.localType, msg.content)
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
isGroup,
|
isGroup,
|
||||||
|
|||||||
@@ -259,9 +259,7 @@ class GroupAnalyticsService {
|
|||||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
|
||||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
|
||||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,11 +514,50 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||||
return this.configService.getAccountDir(dbPath, wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
|
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||||
|
const cached = this.accountDirCache.get(cacheKey)
|
||||||
|
if (cached && existsSync(cached)) return cached
|
||||||
|
if (cached && !existsSync(cached)) {
|
||||||
|
this.accountDirCache.delete(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = join(normalized, cleanedWxid)
|
||||||
|
if (existsSync(direct)) {
|
||||||
|
this.accountDirCache.set(cacheKey, direct)
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isAccountDir(normalized)) {
|
||||||
|
this.accountDirCache.set(cacheKey, normalized)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(normalized)
|
||||||
|
const lowerWxid = cleanedWxid.toLowerCase()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(normalized, entry)
|
||||||
|
if (!this.isDirectory(entryPath)) continue
|
||||||
|
const lowerEntry = entry.toLowerCase()
|
||||||
|
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
||||||
|
if (this.isAccountDir(entryPath)) {
|
||||||
|
this.accountDirCache.set(cacheKey, entryPath)
|
||||||
|
return entryPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCurrentAccountDir(): string | null {
|
private resolveCurrentAccountDir(): string | null {
|
||||||
return this.configService.getAccountDir()
|
const wxid = this.getConfiguredMyWxid()
|
||||||
|
const dbPath = this.getConfiguredDbPath()
|
||||||
|
if (!wxid || !dbPath) return null
|
||||||
|
return this.resolveAccountDir(dbPath, wxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1221,9 +1260,8 @@ export class ImageDecryptService {
|
|||||||
const decryptKey = this.configService.get('decryptKey')
|
const decryptKey = this.configService.get('decryptKey')
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
if (!dbPath || !decryptKey || !wxid) return false
|
if (!dbPath || !decryptKey || !wxid) return false
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
if (!accountDir) return false
|
return await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
return await wcdbService.open(accountDir, decryptKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRowValue(row: any, column: string): any {
|
private getRowValue(row: any, column: string): any {
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
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,13 +15,14 @@
|
|||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ 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,12 +85,60 @@ type InsightFilterMode = 'whitelist' | 'blacklist'
|
|||||||
|
|
||||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||||
|
|
||||||
function insightDebugLine(_level: InsightLogLevel, _message: string): void {
|
let debugLogWriteQueue: Promise<void> = Promise.resolve()
|
||||||
// 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 insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
|
function getInsightDebugLogFilePath(date: Date = new Date()): string {
|
||||||
// Desktop debug log export has been replaced by per-insight request logs.
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return path.join(app.getPath('desktop'), `weflow-ai-insight-debug-${year}-${month}-${day}.log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsightDebugLogEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return ConfigService.getInstance().get('aiInsightDebugLogEnabled') === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendInsightDebugText(text: string): void {
|
||||||
|
if (!isInsightDebugLogEnabled()) return
|
||||||
|
|
||||||
|
let logFilePath = ''
|
||||||
|
try {
|
||||||
|
logFilePath = getInsightDebugLogFilePath()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLogWriteQueue = debugLogWriteQueue
|
||||||
|
.then(() => fs.promises.appendFile(logFilePath, text, 'utf8'))
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insightDebugLine(level: InsightLogLevel, message: string): void {
|
||||||
|
appendInsightDebugText(`[${formatDebugTimestamp()}] [${level}] ${message}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insightDebugSection(level: InsightLogLevel, title: string, payload: unknown): void {
|
||||||
|
const content = typeof payload === 'string'
|
||||||
|
? payload
|
||||||
|
: JSON.stringify(payload, null, 2)
|
||||||
|
|
||||||
|
appendInsightDebugText(
|
||||||
|
`\n========== [${formatDebugTimestamp()}] [${level}] ${title} ==========\n${content}\n========== END ==========\n`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -468,15 +516,9 @@ class InsightService {
|
|||||||
await this.generateInsightForSession({
|
await this.generateInsightForSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
displayName,
|
displayName,
|
||||||
triggerReason: 'test'
|
triggerReason: 'activity'
|
||||||
})
|
})
|
||||||
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||||
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}` }
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1139,7 @@ ${topMentionText}
|
|||||||
private async generateInsightForSession(params: {
|
private async generateInsightForSession(params: {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
triggerReason: InsightRecordTriggerReason
|
triggerReason: 'activity' | 'silence'
|
||||||
silentDays?: number
|
silentDays?: number
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||||
@@ -1108,13 +1150,6 @@ ${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 ? '已配置' : '未配置'}`)
|
||||||
|
|
||||||
@@ -1193,7 +1228,6 @@ ${topMentionText}
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiStartedAt = Date.now()
|
|
||||||
const result = await callApi(
|
const result = await callApi(
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -1202,7 +1236,6 @@ ${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)
|
||||||
@@ -1216,45 +1249,15 @@ ${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
|
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
||||||
if (insightNotificationEnabled) {
|
|
||||||
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
|
||||||
|
|
||||||
// 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
|
// 渠道一:Electron 原生系统通知
|
||||||
await showNotification({
|
if (Notification.isSupported()) {
|
||||||
title: notifTitle,
|
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||||
content: insight,
|
notif.show()
|
||||||
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
|
|
||||||
sessionId,
|
|
||||||
insightRecordId: record.id,
|
|
||||||
channel: 'ai-insight'
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
|
insightLog('WARN', '当前系统不支持原生通知')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渠道二:Telegram Bot 推送(可选)
|
// 渠道二:Telegram Bot 推送(可选)
|
||||||
@@ -1275,7 +1278,7 @@ ${topMentionText}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`)
|
insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`)
|
||||||
this.recordTrigger(sessionId)
|
this.recordTrigger(sessionId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
insightDebugSection(
|
insightDebugSection(
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ 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 = (payload: unknown) => void;
|
type NotificationCallback = (sessionId: string) => void;
|
||||||
|
|
||||||
let notificationCallbacks: NotificationCallback[] = [];
|
let notificationCallbacks: NotificationCallback[] = [];
|
||||||
let notificationCounter = 1;
|
let notificationCounter = 1;
|
||||||
@@ -34,10 +31,10 @@ function clearNotificationState(notificationId: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerNotificationCallback(payload: unknown): void {
|
function triggerNotificationCallback(sessionId: string): void {
|
||||||
for (const callback of notificationCallbacks) {
|
for (const callback of notificationCallbacks) {
|
||||||
try {
|
try {
|
||||||
callback(payload);
|
callback(sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[LinuxNotification] Callback error:", error);
|
console.error("[LinuxNotification] Callback error:", error);
|
||||||
}
|
}
|
||||||
@@ -72,15 +69,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,14 +131,6 @@ class VideoService {
|
|||||||
if (dbPathContainsWxid) {
|
if (dbPathContainsWxid) {
|
||||||
return join(dbPath, 'msg', 'video')
|
return join(dbPath, 'msg', 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 ConfigService 的统一账号目录解析
|
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (accountDir) {
|
|
||||||
return join(accountDir, 'msg', 'video')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退到原始逻辑
|
|
||||||
return join(dbPath, wxid, 'msg', 'video')
|
return join(dbPath, wxid, 'msg', 'video')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,13 +144,6 @@ class VideoService {
|
|||||||
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 ConfigService 的统一账号目录解析
|
|
||||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
|
||||||
if (accountDir) {
|
|
||||||
return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退到原始逻辑
|
|
||||||
return [
|
return [
|
||||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ export class WcdbCore {
|
|||||||
private wcdbUpdateMessage: any = null
|
private wcdbUpdateMessage: any = null
|
||||||
private wcdbDeleteMessage: any = null
|
private wcdbDeleteMessage: any = null
|
||||||
private wcdbGetSessions: any = null
|
private wcdbGetSessions: any = null
|
||||||
private wcdbMarkAllSessionsRead: any = null
|
|
||||||
private wcdbGetMessages: any = null
|
private wcdbGetMessages: any = null
|
||||||
private wcdbGetMessageCount: any = null
|
private wcdbGetMessageCount: any = null
|
||||||
private wcdbGetMessageByServerId: any = null
|
|
||||||
private wcdbGetDisplayNames: any = null
|
private wcdbGetDisplayNames: any = null
|
||||||
private wcdbGetAvatarUrls: any = null
|
private wcdbGetAvatarUrls: any = null
|
||||||
private wcdbGetGroupMemberCount: any = null
|
private wcdbGetGroupMemberCount: any = null
|
||||||
@@ -812,22 +810,12 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
|
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
|
||||||
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
|
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
|
||||||
|
|
||||||
// wcdb_status wcdb_mark_all_sessions_read(wcdb_handle handle, char** out_error)
|
|
||||||
try {
|
|
||||||
this.wcdbMarkAllSessionsRead = this.lib.func('int32 wcdb_mark_all_sessions_read(int64 handle, _Out_ void** outError)')
|
|
||||||
} catch {
|
|
||||||
this.wcdbMarkAllSessionsRead = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
|
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
|
||||||
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
|
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
|
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
|
||||||
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
|
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json)
|
|
||||||
this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)')
|
|
||||||
|
|
||||||
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
|
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||||
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -1272,12 +1260,13 @@ export class WcdbCore {
|
|||||||
/**
|
/**
|
||||||
* 测试数据库连接
|
* 测试数据库连接
|
||||||
*/
|
*/
|
||||||
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||||
try {
|
try {
|
||||||
// 如果当前已经有相同参数的活动连接,直接返回成功
|
// 如果当前已经有相同参数的活动连接,直接返回成功
|
||||||
if (this.handle !== null &&
|
if (this.handle !== null &&
|
||||||
this.currentPath === accountDir &&
|
this.currentPath === dbPath &&
|
||||||
this.currentKey === hexKey) {
|
this.currentKey === hexKey &&
|
||||||
|
this.currentWxid === wxid) {
|
||||||
return { success: true, sessionCount: 0 }
|
return { success: true, sessionCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,9 +1284,9 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接使用账号目录
|
// 构建 db_storage 目录路径
|
||||||
const dbStoragePath = join(accountDir, 'db_storage')
|
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||||
this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`)
|
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
|
||||||
|
|
||||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||||
return { success: false, error: this.formatInitProtectionError(-3001) }
|
return { success: false, error: this.formatInitProtectionError(-3001) }
|
||||||
@@ -1340,9 +1329,9 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 恢复测试前的连接(如果之前有活动连接)
|
// 恢复测试前的连接(如果之前有活动连接)
|
||||||
if (hadActiveConnection && prevPath && prevKey) {
|
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||||
try {
|
try {
|
||||||
await this.open(prevPath, prevKey)
|
await this.open(prevPath, prevKey, prevWxid)
|
||||||
} catch {
|
} catch {
|
||||||
// 恢复失败则保持断开,由调用方处理
|
// 恢复失败则保持断开,由调用方处理
|
||||||
}
|
}
|
||||||
@@ -1547,7 +1536,7 @@ export class WcdbCore {
|
|||||||
/**
|
/**
|
||||||
* 打开数据库
|
* 打开数据库
|
||||||
*/
|
*/
|
||||||
async open(accountDir: string, hexKey: string): Promise<boolean> {
|
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
lastDllInitError = null
|
lastDllInitError = null
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
@@ -1557,8 +1546,9 @@ export class WcdbCore {
|
|||||||
|
|
||||||
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
|
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
|
||||||
if (this.handle !== null &&
|
if (this.handle !== null &&
|
||||||
this.currentPath === accountDir &&
|
this.currentPath === dbPath &&
|
||||||
this.currentKey === hexKey) {
|
this.currentKey === hexKey &&
|
||||||
|
this.currentWxid === wxid) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,12 +1560,12 @@ export class WcdbCore {
|
|||||||
if (!initOk) return false
|
if (!initOk) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbStoragePath = join(accountDir, 'db_storage')
|
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||||
this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true)
|
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
|
||||||
|
|
||||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||||
console.error('数据库目录不存在:', accountDir)
|
console.error('数据库目录不存在:', dbPath)
|
||||||
this.writeLog(`open failed: dbStorage not found for ${accountDir}`)
|
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
|
||||||
lastDllInitError = this.formatInitProtectionError(-3001)
|
lastDllInitError = this.formatInitProtectionError(-3001)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1606,11 +1596,8 @@ export class WcdbCore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从账号目录路径中提取 wxid(目录名)
|
|
||||||
const wxid = basename(accountDir)
|
|
||||||
|
|
||||||
this.handle = handle
|
this.handle = handle
|
||||||
this.currentPath = accountDir
|
this.currentPath = dbPath
|
||||||
this.currentKey = hexKey
|
this.currentKey = hexKey
|
||||||
this.currentWxid = wxid
|
this.currentWxid = wxid
|
||||||
this.currentDbStoragePath = dbStoragePath
|
this.currentDbStoragePath = dbStoragePath
|
||||||
@@ -1628,7 +1615,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
this.writeLog(`open ok handle=${handle}`, true)
|
this.writeLog(`open ok handle=${handle}`, true)
|
||||||
await this.dumpDbStatus('open')
|
await this.dumpDbStatus('open')
|
||||||
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
|
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('打开数据库异常:', e)
|
console.error('打开数据库异常:', e)
|
||||||
@@ -1709,39 +1696,6 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
|
|
||||||
if (!this.ensureReady()) {
|
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
|
||||||
}
|
|
||||||
if (!this.wcdbMarkAllSessionsRead) {
|
|
||||||
return { success: false, error: '当前数据服务版本不支持一键已读' }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
|
||||||
|
|
||||||
const outPtr = [null as any]
|
|
||||||
const result = this.wcdbMarkAllSessionsRead(this.handle, outPtr)
|
|
||||||
let message = ''
|
|
||||||
if (outPtr[0]) {
|
|
||||||
try { message = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
|
||||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
|
||||||
|
|
||||||
if (result !== 0) {
|
|
||||||
this.writeLog(`markAllSessionsRead failed: code=${result} error=${message}`)
|
|
||||||
return { success: false, error: message || `一键已读失败: ${result}` }
|
|
||||||
}
|
|
||||||
this.clearMediaStreamSessionCache()
|
|
||||||
this.writeLog('markAllSessionsRead ok')
|
|
||||||
return { success: true }
|
|
||||||
} catch (e) {
|
|
||||||
this.writeLog(`markAllSessionsRead exception: ${String(e)}`)
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1811,30 +1765,6 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
|
|
||||||
if (!this.ensureReady()) {
|
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const outPtr = [null as any]
|
|
||||||
const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr)
|
|
||||||
if (result !== 0) {
|
|
||||||
return { success: false, error: `查询消息失败: ${result}` }
|
|
||||||
}
|
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
|
||||||
if (!jsonStr) {
|
|
||||||
return { success: true, row: null }
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(jsonStr)
|
|
||||||
if (!parsed || Object.keys(parsed).length === 0) {
|
|
||||||
return { success: true, row: null }
|
|
||||||
}
|
|
||||||
return { success: true, row: parsed }
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: String(e) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
|||||||
@@ -154,17 +154,15 @@ export class WcdbService {
|
|||||||
/**
|
/**
|
||||||
* 测试数据库连接
|
* 测试数据库连接
|
||||||
*/
|
*/
|
||||||
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||||
return this.callWorker('testConnection', { accountDir, hexKey })
|
return this.callWorker('testConnection', { dbPath, hexKey, wxid })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开数据库
|
* 打开数据库
|
||||||
* @param accountDir 账号目录的完整路径
|
|
||||||
* @param hexKey 解密密钥
|
|
||||||
*/
|
*/
|
||||||
async open(accountDir: string, hexKey: string): Promise<boolean> {
|
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
|
||||||
return this.callWorker('open', { accountDir, hexKey })
|
return this.callWorker('open', { dbPath, hexKey, wxid })
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLastInitError(): Promise<string | null> {
|
async getLastInitError(): Promise<string | null> {
|
||||||
@@ -204,10 +202,6 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSessions')
|
return this.callWorker('getSessions')
|
||||||
}
|
}
|
||||||
|
|
||||||
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
|
|
||||||
return this.callWorker('markAllSessionsRead')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息列表
|
* 获取消息列表
|
||||||
*/
|
*/
|
||||||
@@ -229,13 +223,6 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageCount', { sessionId })
|
return this.callWorker('getMessageCount', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 server_id 查询单条消息
|
|
||||||
*/
|
|
||||||
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
|
|
||||||
return this.callWorker('getMessageByServerId', { sessionId, svrid })
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
return this.callWorker('getMessageCounts', { sessionIds })
|
return this.callWorker('getMessageCounts', { sessionIds })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ if (parentPort) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.accountDir, payload.hexKey)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
case 'open':
|
case 'open':
|
||||||
result = await core.open(payload.accountDir, payload.hexKey)
|
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
case 'getLastInitError':
|
case 'getLastInitError':
|
||||||
result = core.getLastInitError()
|
result = core.getLastInitError()
|
||||||
@@ -50,9 +50,6 @@ if (parentPort) {
|
|||||||
case 'getSessions':
|
case 'getSessions':
|
||||||
result = await core.getSessions()
|
result = await core.getSessions()
|
||||||
break
|
break
|
||||||
case 'markAllSessionsRead':
|
|
||||||
result = await core.markAllSessionsRead()
|
|
||||||
break
|
|
||||||
case 'getMessages':
|
case 'getMessages':
|
||||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
@@ -62,9 +59,6 @@ if (parentPort) {
|
|||||||
case 'getMessageCount':
|
case 'getMessageCount':
|
||||||
result = await core.getMessageCount(payload.sessionId)
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
break
|
break
|
||||||
case 'getMessageByServerId':
|
|
||||||
result = await core.getMessageByServerId(payload.sessionId, payload.svrid)
|
|
||||||
break
|
|
||||||
case 'getMessageCounts':
|
case 'getMessageCounts':
|
||||||
result = await core.getMessageCounts(payload.sessionIds)
|
result = await core.getMessageCounts(payload.sessionIds)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ let linuxNotificationService:
|
|||||||
| null = null;
|
| null = null;
|
||||||
|
|
||||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||||
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
|
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||||
|
|
||||||
export function setNotificationNavigateHandler(
|
export function setNotificationNavigateHandler(
|
||||||
callback: (payload: unknown) => void,
|
callback: (sessionId: string) => void,
|
||||||
) {
|
) {
|
||||||
onNotificationNavigate = callback;
|
onNotificationNavigate = callback;
|
||||||
}
|
}
|
||||||
@@ -109,33 +109,25 @@ 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 : "";
|
||||||
const channel = typeof data.channel === "string" ? data.channel : "";
|
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||||
const isAiInsightNotification = channel === "ai-insight";
|
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||||
|
|
||||||
if (isAiInsightNotification) {
|
if (!isSystemNotification && filterMode !== "all") {
|
||||||
const enabled = await config.get("aiInsightNotificationEnabled");
|
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||||
if (enabled === false) return; // 默认为 true
|
if (filterMode === "whitelist" && !isInList) {
|
||||||
} else {
|
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||||
const enabled = await config.get("notificationEnabled");
|
return;
|
||||||
if (enabled === false) return; // 默认为 true
|
}
|
||||||
|
if (filterMode === "blacklist" && isInList) {
|
||||||
// 检查会话过滤
|
// 黑名单模式:在列表中则不显示
|
||||||
const filterMode = config.get("notificationFilterMode") || "all";
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,9 +176,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,14 +249,14 @@ export async function registerNotificationHandlers() {
|
|||||||
await linuxNotificationModule.initLinuxNotificationService();
|
await linuxNotificationModule.initLinuxNotificationService();
|
||||||
|
|
||||||
// 在Linux上注册通知点击回调
|
// 在Linux上注册通知点击回调
|
||||||
linuxNotificationModule.onNotificationAction((payload: unknown) => {
|
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||||
console.log(
|
console.log(
|
||||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||||
payload,
|
sessionId,
|
||||||
);
|
);
|
||||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||||
if (onNotificationNavigate) {
|
if (onNotificationNavigate) {
|
||||||
onNotificationNavigate(payload);
|
onNotificationNavigate(sessionId);
|
||||||
} else {
|
} else {
|
||||||
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||||
|
|||||||
1656
package-lock.json
generated
1656
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -23,7 +23,6 @@
|
|||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/sudo-prompt": "^9.3.2",
|
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
@@ -35,15 +34,16 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.6",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"@vscode/sudo-prompt": "^9.3.2",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -51,14 +51,13 @@
|
|||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"esbuild": "^0.28.0",
|
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.10",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
15
src/App.tsx
15
src/App.tsx
@@ -28,7 +28,6 @@ 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'
|
||||||
@@ -320,19 +319,6 @@ 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) {
|
||||||
@@ -717,7 +703,6 @@ 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,9 +7,6 @@ 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
|
||||||
@@ -19,7 +16,7 @@ export interface NotificationData {
|
|||||||
interface NotificationToastProps {
|
interface NotificationToastProps {
|
||||||
data: NotificationData | null
|
data: NotificationData | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onClick: (data: NotificationData) => void
|
onClick: (sessionId: string) => 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
|
||||||
@@ -67,7 +64,7 @@ export function NotificationToast({
|
|||||||
setIsVisible(false)
|
setIsVisible(false)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose()
|
onClose()
|
||||||
onClick(currentData)
|
onClick(currentData.sessionId)
|
||||||
}, 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, Sparkles } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore } 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,15 +344,6 @@ 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"
|
||||||
|
|||||||
@@ -2245,28 +2245,11 @@
|
|||||||
box-shadow: 0 0 0 2px var(--primary-light);
|
box-shadow: 0 0 0 2px var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-unavailable.error {
|
|
||||||
background: rgba(239, 68, 68, 0.08);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: rgba(239, 68, 68, 0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-unavailable:disabled {
|
.image-unavailable:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-error-reason {
|
|
||||||
font-size: 11px;
|
|
||||||
color: rgba(239, 68, 68, 0.9);
|
|
||||||
max-width: 140px;
|
|
||||||
word-break: break-word;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-action {
|
.image-action {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-quaternary);
|
color: var(--text-quaternary);
|
||||||
|
|||||||
@@ -1453,7 +1453,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(260)
|
const [sidebarWidth, setSidebarWidth] = useState(260)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const [isMarkingAllSessionsRead, setIsMarkingAllSessionsRead] = useState(false)
|
|
||||||
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
||||||
const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false)
|
const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false)
|
||||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
@@ -3131,35 +3130,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMarkAllSessionsRead = async () => {
|
|
||||||
if (isMarkingAllSessionsRead || isLoadingSessions || isRefreshingSessions) return
|
|
||||||
setIsMarkingAllSessionsRead(true)
|
|
||||||
setConnectionError(null)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.chat.markAllSessionsRead()
|
|
||||||
if (!result.success) {
|
|
||||||
setConnectionError(result.error || '一键已读失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestSessions = useChatStore.getState().sessions || []
|
|
||||||
const nextSessions = latestSessions.map((session) => (
|
|
||||||
session.unreadCount > 0 ? { ...session, unreadCount: 0 } : session
|
|
||||||
))
|
|
||||||
setSessions(nextSessions)
|
|
||||||
sessionsRef.current = nextSessions
|
|
||||||
|
|
||||||
const scope = await resolveChatCacheScope()
|
|
||||||
persistSessionListCache(scope, nextSessions)
|
|
||||||
await loadSessions({ silent: true })
|
|
||||||
} catch (e) {
|
|
||||||
console.error('一键已读失败:', e)
|
|
||||||
setConnectionError(`一键已读失败: ${String(e)}`)
|
|
||||||
} finally {
|
|
||||||
setIsMarkingAllSessionsRead(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次)
|
// 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次)
|
||||||
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
|
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
|
||||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||||
@@ -6805,15 +6775,6 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="icon-btn refresh-btn mark-read-btn"
|
|
||||||
onClick={handleMarkAllSessionsRead}
|
|
||||||
disabled={isMarkingAllSessionsRead || isLoadingSessions || isRefreshingSessions}
|
|
||||||
title="一键已读"
|
|
||||||
aria-label="一键已读"
|
|
||||||
>
|
|
||||||
{isMarkingAllSessionsRead ? <Loader2 size={16} className="spin" /> : <CheckSquare size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 折叠群 header */}
|
{/* 折叠群 header */}
|
||||||
@@ -8409,8 +8370,6 @@ function MessageBubble({
|
|||||||
|
|
||||||
// State variables...
|
// State variables...
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(false)
|
||||||
const [imageErrorReason, setImageErrorReason] = useState<string | undefined>(undefined)
|
|
||||||
const [imageFailureKind, setImageFailureKind] = useState<'not_found' | 'decrypt_failed' | undefined>(undefined)
|
|
||||||
const [imageLoading, setImageLoading] = useState(false)
|
const [imageLoading, setImageLoading] = useState(false)
|
||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
|
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
|
||||||
@@ -8798,11 +8757,7 @@ function MessageBubble({
|
|||||||
if (result.success && result.localPath) {
|
if (result.success && result.localPath) {
|
||||||
const renderPath = toRenderableImageSrc(result.localPath)
|
const renderPath = toRenderableImageSrc(result.localPath)
|
||||||
if (!renderPath) {
|
if (!renderPath) {
|
||||||
if (!silent) {
|
if (!silent) setImageError(true)
|
||||||
setImageError(true)
|
|
||||||
setImageErrorReason('路径无效')
|
|
||||||
setImageFailureKind('decrypt_failed')
|
|
||||||
}
|
|
||||||
return { success: false }
|
return { success: false }
|
||||||
}
|
}
|
||||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||||
@@ -8814,10 +8769,6 @@ function MessageBubble({
|
|||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
return { ...result, localPath: renderPath }
|
return { ...result, localPath: renderPath }
|
||||||
} else if (!silent && result.error) {
|
|
||||||
setImageError(true)
|
|
||||||
setImageErrorReason(result.error)
|
|
||||||
setImageFailureKind(result.failureKind)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8834,17 +8785,9 @@ function MessageBubble({
|
|||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
return { success: true, localPath: dataUrl }
|
return { success: true, localPath: dataUrl }
|
||||||
}
|
}
|
||||||
if (!silent) {
|
if (!silent) setImageError(true)
|
||||||
setImageError(true)
|
} catch {
|
||||||
setImageErrorReason('图片数据获取失败')
|
if (!silent) setImageError(true)
|
||||||
setImageFailureKind('not_found')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!silent) {
|
|
||||||
setImageError(true)
|
|
||||||
setImageErrorReason(e instanceof Error ? e.message : '解密异常')
|
|
||||||
setImageFailureKind('decrypt_failed')
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setImageLoading(false)
|
if (!silent) setImageLoading(false)
|
||||||
imageDecryptPendingRef.current = false
|
imageDecryptPendingRef.current = false
|
||||||
@@ -9466,14 +9409,8 @@ function MessageBubble({
|
|||||||
appMsgTextCache.set(selector, value)
|
appMsgTextCache.set(selector, value)
|
||||||
return value
|
return value
|
||||||
}, [appMsgDoc, appMsgTextCache])
|
}, [appMsgDoc, appMsgTextCache])
|
||||||
const decodeHtmlEntities = useCallback((text: string): string => {
|
|
||||||
const textarea = document.createElement('textarea')
|
|
||||||
textarea.innerHTML = text
|
|
||||||
return textarea.value
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const queryPreferredQuotedContent = useCallback((): string => {
|
const queryPreferredQuotedContent = useCallback((): string => {
|
||||||
if (message.quotedContent) return decodeHtmlEntities(message.quotedContent)
|
if (message.quotedContent) return message.quotedContent
|
||||||
const candidates = [
|
const candidates = [
|
||||||
'refermsg > selectedcontent',
|
'refermsg > selectedcontent',
|
||||||
'refermsg > selectedtext',
|
'refermsg > selectedtext',
|
||||||
@@ -9490,10 +9427,10 @@ function MessageBubble({
|
|||||||
]
|
]
|
||||||
for (const selector of candidates) {
|
for (const selector of candidates) {
|
||||||
const value = queryAppMsgText(selector)
|
const value = queryAppMsgText(selector)
|
||||||
if (value) return decodeHtmlEntities(value)
|
if (value) return value
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}, [message.quotedContent, queryAppMsgText, decodeHtmlEntities])
|
}, [message.quotedContent, queryAppMsgText])
|
||||||
const appMsgThumbRawCandidate = useMemo(() => (
|
const appMsgThumbRawCandidate = useMemo(() => (
|
||||||
message.linkThumb ||
|
message.linkThumb ||
|
||||||
message.appMsgThumbUrl ||
|
message.appMsgThumbUrl ||
|
||||||
@@ -9687,7 +9624,7 @@ function MessageBubble({
|
|||||||
// 渲染消息内容
|
// 渲染消息内容
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
const imageContent = (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={imageContainerRef}
|
ref={imageContainerRef}
|
||||||
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
|
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
|
||||||
@@ -9699,15 +9636,14 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
) : imageError || !imageLocalPath ? (
|
) : imageError || !imageLocalPath ? (
|
||||||
<button
|
<button
|
||||||
className={`image-unavailable ${imageClicked ? 'clicked' : ''} ${imageError ? 'error' : ''}`}
|
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
disabled={imageLoading}
|
disabled={imageLoading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ImageIcon size={24} />
|
<ImageIcon size={24} />
|
||||||
<span>{imageError ? '解密失败' : '图片未解密'}</span>
|
<span>图片未解密</span>
|
||||||
{imageErrorReason && <span className="image-error-reason">{imageErrorReason}</span>}
|
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
|
||||||
<span className="image-action">{imageClicked ? '已点击…' : '点击重试'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -9723,8 +9659,6 @@ function MessageBubble({
|
|||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
setImageErrorReason(undefined)
|
|
||||||
setImageFailureKind(undefined)
|
|
||||||
stabilizeImageScrollAfterResize()
|
stabilizeImageScrollAfterResize()
|
||||||
releaseImageStageLock()
|
releaseImageStageLock()
|
||||||
}}
|
}}
|
||||||
@@ -9745,24 +9679,13 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasQuote) {
|
|
||||||
return renderBubbleWithQuote(
|
|
||||||
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
|
|
||||||
imageContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="bubble-content">{imageContent}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频消息
|
// 视频消息
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
let videoContent: React.ReactNode
|
|
||||||
|
|
||||||
// 未进入可视区域时显示占位符
|
// 未进入可视区域时显示占位符
|
||||||
if (!isVideoVisible) {
|
if (!isVideoVisible) {
|
||||||
videoContent = (
|
return (
|
||||||
<div className="video-placeholder" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
|
<div className="video-placeholder" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
@@ -9770,16 +9693,20 @@ function MessageBubble({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (videoLoading) {
|
}
|
||||||
// 加载中
|
|
||||||
videoContent = (
|
// 加载中
|
||||||
|
if (videoLoading) {
|
||||||
|
return (
|
||||||
<div className="video-loading" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
|
<div className="video-loading" ref={videoContainerRef as React.RefObject<HTMLDivElement>}>
|
||||||
<Loader2 size={20} className="spin" />
|
<Loader2 size={20} className="spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (!videoInfo?.exists || !videoInfo.videoUrl) {
|
}
|
||||||
// 视频不存在 - 添加点击重试功能
|
|
||||||
videoContent = (
|
// 视频不存在 - 添加点击重试功能
|
||||||
|
if (!videoInfo?.exists || !videoInfo.videoUrl) {
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
className={`video-unavailable ${videoClicked ? 'clicked' : ''}`}
|
className={`video-unavailable ${videoClicked ? 'clicked' : ''}`}
|
||||||
ref={videoContainerRef as React.RefObject<HTMLButtonElement>}
|
ref={videoContainerRef as React.RefObject<HTMLButtonElement>}
|
||||||
@@ -9799,36 +9726,27 @@ function MessageBubble({
|
|||||||
<span className="video-action">{videoClicked ? '已点击…' : '点击重试'}</span>
|
<span className="video-action">{videoClicked ? '已点击…' : '点击重试'}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
// 默认显示缩略图,点击打开独立播放窗口
|
|
||||||
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
|
// 默认显示缩略图,点击打开独立播放窗口
|
||||||
videoContent = (
|
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
|
||||||
|
return (
|
||||||
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
<div className="video-thumb-wrapper" ref={videoContainerRef as React.RefObject<HTMLDivElement>} onClick={handlePlayVideo}>
|
||||||
{thumbSrc ? (
|
{thumbSrc ? (
|
||||||
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
|
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" loading="lazy" decoding="async" />
|
||||||
) : (
|
) : (
|
||||||
<div className="video-thumb-placeholder">
|
<div className="video-thumb-placeholder">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="video-play-button">
|
|
||||||
<Play size={32} fill="white" />
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="video-play-button">
|
||||||
|
<Play size={32} fill="white" />
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
if (hasQuote) {
|
|
||||||
return renderBubbleWithQuote(
|
|
||||||
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
|
|
||||||
videoContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="bubble-content">{videoContent}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVoice) {
|
if (isVoice) {
|
||||||
@@ -9916,7 +9834,7 @@ function MessageBubble({
|
|||||||
void requestVoiceTranscript()
|
void requestVoiceTranscript()
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceContent = (
|
return (
|
||||||
<div className="voice-stack">
|
<div className="voice-stack">
|
||||||
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
||||||
<button
|
<button
|
||||||
@@ -9999,15 +9917,6 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasQuote) {
|
|
||||||
return renderBubbleWithQuote(
|
|
||||||
renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))),
|
|
||||||
voiceContent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="bubble-content">{voiceContent}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 名片消息
|
// 名片消息
|
||||||
@@ -10111,30 +10020,10 @@ function MessageBubble({
|
|||||||
return <span className="quoted-type-label">[动画表情]</span>
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接类消息:需区分真正的链接和嵌套引用
|
|
||||||
// 当一个引用了别的消息的消息被引用(B引用A,C又引用B),那么 B 在 C 的 refermsg 里 type=49
|
|
||||||
// 与此同时,一个链接的 type 也是 49,这可能意味着 49 是一个更高级别的分类
|
|
||||||
// 因此,不能将 type=49 的引用信息一律视为链接,它也可能是嵌套引用。那么怎么区分呢?
|
|
||||||
// 答:嵌套引用的 referContent 中 xmlType=57,真正的链接 xmlType=49 或 5
|
|
||||||
// 对于更多层的嵌套引用,微信不会保存所有层的信息,因此和两层的情况差不多
|
|
||||||
// 注意:需从原始 XML 获取 refermsg > content,而非后端处理过的 quotedContent
|
|
||||||
if (referType === '49') {
|
|
||||||
try {
|
|
||||||
const rawReferContent = q('refermsg > content') || ''
|
|
||||||
const innerDoc = new DOMParser().parseFromString(rawReferContent, 'text/xml')
|
|
||||||
const innerXmlType = innerDoc.querySelector('appmsg > type')?.textContent?.trim()
|
|
||||||
if (innerXmlType === '57') {
|
|
||||||
const innerTitle = innerDoc.querySelector('title')?.textContent?.trim() || ''
|
|
||||||
if (innerTitle) return <>{renderTextWithEmoji(cleanMessageContent(innerTitle))}</>
|
|
||||||
}
|
|
||||||
} catch { /* 解析失败降级 */ }
|
|
||||||
return <span className="quoted-type-label">[链接]</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
// 各类型名称映射
|
// 各类型名称映射
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
'3': '图片', '34': '语音', '43': '视频',
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
'50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
}
|
}
|
||||||
if (referType && typeLabels[referType]) {
|
if (referType && typeLabels[referType]) {
|
||||||
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
@@ -10507,29 +10396,9 @@ function MessageBubble({
|
|||||||
} catch { /* 解析失败降级 */ }
|
} catch { /* 解析失败降级 */ }
|
||||||
return <span className="quoted-type-label">[动画表情]</span>
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
}
|
}
|
||||||
// 链接类消息:需区分真正的链接和嵌套引用
|
|
||||||
// 当一个引用了别的消息的消息被引用(B引用A,C又引用B),那么 B 在 C 的 refermsg 里 type=49
|
|
||||||
// 与此同时,一个链接的 type 也是 49,这可能意味着 49 是一个更高级别的分类
|
|
||||||
// 因此,不能将 type=49 的引用信息一律视为链接,它也可能是嵌套引用。那么怎么区分呢?
|
|
||||||
// 答:嵌套引用的 referContent 中 xmlType=57,真正的链接 xmlType=49 或 5
|
|
||||||
// 对于更多层的嵌套引用,微信不会保存所有层的信息,因此和两层的情况差不多
|
|
||||||
// 注意:需从原始 XML 获取 refermsg > content,而非后端处理过的 quotedContent
|
|
||||||
if (referType === '49') {
|
|
||||||
try {
|
|
||||||
const rawReferContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
|
||||||
const innerDoc = new DOMParser().parseFromString(rawReferContent, 'text/xml')
|
|
||||||
const innerXmlType = innerDoc.querySelector('appmsg > type')?.textContent?.trim()
|
|
||||||
if (innerXmlType === '57') {
|
|
||||||
const innerTitle = innerDoc.querySelector('title')?.textContent?.trim() || ''
|
|
||||||
if (innerTitle) return <>{renderTextWithEmoji(cleanMessageContent(innerTitle))}</>
|
|
||||||
}
|
|
||||||
} catch { /* 解析失败降级 */ }
|
|
||||||
return <span className="quoted-type-label">[链接]</span>
|
|
||||||
}
|
|
||||||
// 各类型名称映射
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
'3': '图片', '34': '语音', '43': '视频',
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
'50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
}
|
}
|
||||||
if (referType && typeLabels[referType]) {
|
if (referType && typeLabels[referType]) {
|
||||||
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
|
|||||||
@@ -1,612 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
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,9 +29,6 @@ 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,
|
||||||
@@ -73,17 +70,8 @@ export default function NotificationWindow() {
|
|||||||
window.electronAPI.notification?.close()
|
window.electronAPI.notification?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = (data: NotificationData) => {
|
const handleClick = (sessionId: string) => {
|
||||||
if (data.channel === 'ai-insight') {
|
window.electronAPI.notification?.click(sessionId)
|
||||||
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
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ 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[]>([])
|
||||||
@@ -327,6 +326,7 @@ 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,7 +458,6 @@ 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()
|
||||||
@@ -513,7 +512,6 @@ 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)
|
||||||
@@ -590,6 +588,7 @@ 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)
|
||||||
@@ -616,6 +615,7 @@ 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,29 +1903,6 @@ 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>
|
||||||
@@ -3232,7 +3209,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>
|
||||||
@@ -3946,6 +3923,32 @@ 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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1038,13 +1038,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
className="field-input"
|
className="field-input"
|
||||||
placeholder="64 位十六进制密钥"
|
placeholder="64 位十六进制密钥"
|
||||||
value={decryptKey}
|
value={decryptKey}
|
||||||
onChange={(e) => {
|
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||||
const value = e.target.value.trim()
|
|
||||||
setDecryptKey(value)
|
|
||||||
if (value.length === 64) {
|
|
||||||
setHasReacquiredDbKey(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||||
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showDecryptKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
@@ -1177,7 +1171,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="field-hint" style={{ marginTop: '8px' }}>
|
<div className="field-hint" style={{ marginTop: '8px' }}>
|
||||||
图片密钥已改为自动计算。仅当"缓存计算 + 本地校验通过"时会自动跳过本步骤;若失败可使用内存扫描兜底。
|
图片密钥已改为自动计算。仅当“缓存计算 + 本地校验通过”时会自动跳过本步骤;若失败可使用内存扫描兜底。
|
||||||
</div>
|
</div>
|
||||||
{isImageKeyVerified && (
|
{isImageKeyVerified && (
|
||||||
<div className="status-message is-success" style={{ marginTop: '8px' }}>
|
<div className="status-message is-success" style={{ marginTop: '8px' }}>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ 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',
|
||||||
@@ -1678,15 +1677,6 @@ 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)
|
||||||
|
|||||||
76
src/types/electron.d.ts
vendored
76
src/types/electron.d.ts
vendored
@@ -21,72 +21,6 @@ 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
|
||||||
@@ -233,14 +167,13 @@ 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; channel?: string; insightRecordId?: string; targetRoute?: string }) => Promise<{ success?: boolean; error?: string } | void>
|
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
|
click: (sessionId: 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>
|
||||||
@@ -338,7 +271,6 @@ export interface ElectronAPI {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => Promise<{ success: boolean; error?: string }>
|
connect: () => Promise<{ success: boolean; error?: string }>
|
||||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||||
markAllSessionsRead: () => Promise<{ success: boolean; error?: string }>
|
|
||||||
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
getAntiRevokeSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||||
getSessionStatuses: (usernames: string[]) => Promise<{
|
getSessionStatuses: (usernames: string[]) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -1302,10 +1234,6 @@ 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