diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 60f896e..dd55157 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -16,6 +16,7 @@ interface ExportWorkerConfig { resourcesPath?: string userDataPath?: string logEnabled?: boolean + isPackaged?: boolean } const config = workerData as ExportWorkerConfig @@ -150,7 +151,10 @@ async function run() { decryptKey: config.decryptKey, myWxid: config.myWxid, imageXorKey: config.imageXorKey, - imageAesKey: config.imageAesKey + imageAesKey: config.imageAesKey, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) const onProgress = (progress: any) => queueProgress(progress) @@ -173,7 +177,10 @@ async function run() { chatService.setRuntimeConfig({ dbPath: config.dbPath, decryptKey: config.decryptKey, - myWxid: config.myWxid + myWxid: config.myWxid, + resourcesPath: config.resourcesPath, + appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname, + isPackaged: config.isPackaged }) result = await contactExportService.exportContacts( String(config.outputDir || ''), diff --git a/electron/main.ts b/electron/main.ts index 39ddc61..9c932ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,8 @@ import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' import { insightRecordService } from './services/insightRecordService' +import { insightProfileService } from './services/insightProfileService' +import { groupSummaryService } from './services/groupSummaryService' import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' import { backupService } from './services/backupService' @@ -397,13 +399,7 @@ let keyService: any if (process.platform === 'darwin') { keyService = new KeyServiceMac() } else if (process.platform === 'linux') { - // const { KeyServiceLinux } = require('./services/keyServiceLinux') - // keyService = new KeyServiceLinux() - - import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => { - keyService = new KeyServiceLinux(); - }); - + keyService = new KeyServiceLinux() } else { keyService = new KeyService() } @@ -444,6 +440,7 @@ const pruneChatHistoryPayloadStore = (): void => { } type WindowCloseBehavior = 'ask' | 'tray' | 'quit' +type CloseRestoreMethod = 'tray' | 'dock' // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -817,29 +814,47 @@ const isSilentStartupEnabled = (): boolean => { return configService?.get('silentStartup') === true } +const getCloseRestoreMethod = (): CloseRestoreMethod | null => { + if (tray) return 'tray' + if (process.platform === 'darwin') return 'dock' + return null +} + +const canKeepMainWindowInBackground = (): boolean => { + return getCloseRestoreMethod() !== null +} + +const getPlatformIconName = (): string => { + if (process.platform === 'linux') return 'icon.png' + if (process.platform === 'darwin') return 'icon.icns' + return 'icon.ico' +} + +const resolveAppIconPath = (): string => { + const iconName = getPlatformIconName() + if (!process.env.VITE_DEV_SERVER_URL) { + return join(process.resourcesPath, iconName) + } + if (process.platform === 'darwin') { + return join(__dirname, '../resources/icons/macos/icon.icns') + } + return join(__dirname, `../public/${iconName}`) +} + const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { if (isClosePromptVisible) return isClosePromptVisible = true + const restoreMethod = getCloseRestoreMethod() win.webContents.send('window:confirmCloseRequested', { - canMinimizeToTray: Boolean(tray) + canMinimizeToTray: restoreMethod !== null, + restoreMethod: restoreMethod ?? undefined }) } function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options - let iconName = 'icon.ico'; - if (process.platform === 'linux') { - iconName = 'icon.png'; - } else if (process.platform === 'darwin') { - iconName = 'icon.icns'; - } - - const isDev = !!process.env.VITE_DEV_SERVER_URL - - const iconPath = isDev - ? join(__dirname, `../public/${iconName}`) - : join(process.resourcesPath, iconName); + const iconPath = resolveAppIconPath() const win = new BrowserWindow({ width: 1400, @@ -912,7 +927,7 @@ function createWindow(options: { autoShow?: boolean } = {}) { return } - if (closeBehavior === 'tray' && tray) { + if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) { win.hide() return } @@ -1775,6 +1790,7 @@ function registerIpcHandlers() { } void messagePushService.handleConfigChanged(key) void insightService.handleConfigChanged(key) + void groupSummaryService.handleConfigChanged(key) return result }) @@ -1792,6 +1808,7 @@ function registerIpcHandlers() { sessionId?: string startTime?: number endTime?: number + sourceType?: 'insight' | 'message_analysis' | 'all' limit?: number offset?: number }) => { @@ -1818,6 +1835,30 @@ function registerIpcHandlers() { return insightService.triggerTest() }) + ipcMain.handle('insight:triggerSessionInsight', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => { + return insightService.triggerSessionInsight(payload) + }) + + ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => { + return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : []) + }) + + ipcMain.handle('insight:generateProfile', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => { + return insightProfileService.generateProfile(payload) + }) + + ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => { + return insightProfileService.cancelProfile(sessionId) + }) + ipcMain.handle('insight:generateFootprintInsight', async (_, payload: { rangeLabel: string summary: { @@ -1834,6 +1875,54 @@ function registerIpcHandlers() { return insightService.generateFootprintInsight(payload) }) + ipcMain.handle('insight:generateMessageInsight', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }) => { + return insightService.generateMessageInsight(payload) + }) + + ipcMain.handle('groupSummary:listRecords', async (_, filters?: { + sessionId?: string + startTime?: number + endTime?: number + limit?: number + offset?: number + }) => { + return groupSummaryService.listRecords(filters || {}) + }) + + ipcMain.handle('groupSummary:getRecord', async (_, id: string) => { + return groupSummaryService.getRecord(id) + }) + + ipcMain.handle('groupSummary:triggerManual', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }) => { + return groupSummaryService.triggerManual(payload) + }) + + ipcMain.handle('groupSummary:triggerDay', async (_, payload: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }) => { + return groupSummaryService.triggerDay(payload) + }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { try { if (!configService) { @@ -1870,6 +1959,7 @@ function registerIpcHandlers() { configService?.clear() messagePushService.handleConfigCleared() insightService.handleConfigCleared() + groupSummaryService.handleConfigCleared() return true }) @@ -2113,7 +2203,7 @@ function registerIpcHandlers() { try { if (action === 'tray') { - if (tray) { + if (canKeepMainWindowInBackground()) { mainWindow.hide() return true } @@ -2349,8 +2439,8 @@ function registerIpcHandlers() { return chatService.getContactTypeCounts() }) - ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { - return chatService.getSessionMessageCounts(sessionIds) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => { + return chatService.getSessionMessageCounts(sessionIds, options) }) ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { @@ -3213,7 +3303,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath, userDataPath, - logEnabled + logEnabled, + isPackaged: app.isPackaged } }) @@ -3344,7 +3435,8 @@ function registerIpcHandlers() { imageAesKey: imageKeys.aesKey, resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) @@ -3411,7 +3503,8 @@ function registerIpcHandlers() { myWxid: String(cfg.getMyWxidCleaned() || '').trim(), resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'), userDataPath: app.getPath('userData'), - logEnabled: cfg.get('logEnabled') + logEnabled: cfg.get('logEnabled'), + isPackaged: app.isPackaged } }) @@ -4206,6 +4299,7 @@ app.whenReady().then(async () => { }) messagePushService.start() insightService.start() + groupSummaryService.start() await delay(200) // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等) @@ -4259,18 +4353,7 @@ app.whenReady().then(async () => { ensureWeChatRequestHeaderInterceptor() mainWindow = createWindow({ autoShow: false }) - let iconName = 'icon.ico'; - if (process.platform === 'linux') { - iconName = 'icon.png'; - } else if (process.platform === 'darwin') { - iconName = 'icon.icns'; - } - - const isDev = !!process.env.VITE_DEV_SERVER_URL - - const resolvedTrayIcon = isDev - ? join(__dirname, `../public/${iconName}`) - : join(process.resourcesPath, iconName); + const resolvedTrayIcon = resolveAppIconPath() try { @@ -4348,6 +4431,14 @@ app.whenReady().then(async () => { await httpService.autoStart() app.on('activate', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) { + mainWindow.show() + } + mainWindow.focus() + return + } + if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow() } @@ -4364,6 +4455,7 @@ const shutdownAppServices = async (): Promise => { destroyNotificationWindow() messagePushService.stop() insightService.stop() + groupSummaryService.stop() // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 const forceExitTimer = setTimeout(() => { console.warn('[App] Force exit after timeout') diff --git a/electron/preload.ts b/electron/preload.ts index 53844af..98e97f3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron' +type CloseConfirmPayload = { + canMinimizeToTray: boolean + restoreMethod?: 'tray' | 'dock' +} + // 暴露给渲染进程的 API contextBridge.exposeInMainWorld('electronAPI', { // 配置 @@ -106,8 +111,8 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) }, close: () => ipcRenderer.send('window:close'), - onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => { - const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload) + onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => { + const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload) ipcRenderer.on('window:confirmCloseRequested', listener) return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener) }, @@ -195,7 +200,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), - getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options), enrichSessionsContactInfo: ( usernames: string[], options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } @@ -585,6 +590,18 @@ contextBridge.exposeInMainWorld('electronAPI', { markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id), clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters), triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), + triggerSessionInsight: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => ipcRenderer.invoke('insight:triggerSessionInsight', payload), + listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds), + generateProfile: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + }) => ipcRenderer.invoke('insight:generateProfile', payload), + cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId), generateFootprintInsight: (payload: { rangeLabel: string summary: { @@ -597,7 +614,37 @@ contextBridge.exposeInMainWorld('electronAPI', { } privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> - }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) + }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload), + generateMessageInsight: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }) => ipcRenderer.invoke('insight:generateMessageInsight', payload) + }, + + groupSummary: { + listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters), + getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id), + triggerManual: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }) => ipcRenderer.invoke('groupSummary:triggerManual', payload), + triggerDay: (payload: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }) => ipcRenderer.invoke('groupSummary:triggerDay', payload) }, social: { diff --git a/electron/services/accountDirResolver.ts b/electron/services/accountDirResolver.ts index 1ad8c7a..7c440fb 100644 --- a/electron/services/accountDirResolver.ts +++ b/electron/services/accountDirResolver.ts @@ -1,8 +1,30 @@ +/** + * 账号目录解析器(Worker 线程 / 主进程通用) + * + * 职责:在 dbPath 根目录下,根据传入的 wxid,找出微信"实际写入数据" + * 的那个账号子目录,例如: + * dbPath = <微信数据根目录> + * wxid = customwxid_abcd 或 customwxid + * 期望返回 <微信数据根目录>/customwxid_abcd(带后缀、有 session.db 的那个) + * + * 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件 + * 也会在 Worker 线程中被加载,无法依赖 electron-store。 + */ import { existsSync, readdirSync, statSync } from 'fs' import { join } from 'path' +// 解析结果缓存(进程内,避免重复 IO)。key = `${dbPath}|${cleanedWxid}` const accountDirCache = new Map() +/** + * 把 wxid 字符串"标准化"为目录前缀。 + * - wxid_xxx_yyyy → wxid_xxx (wxid_ 后只取第一段) + * - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid) + * - 其他形式 → 原样返回 + * + * 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。 + * 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。 + */ const cleanAccountDirName = (dirName: string): string => { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -27,6 +49,39 @@ const isDirectory = (path: string): boolean => { } } +/** + * 解析账号目录的真实绝对路径。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现存在的两处严重缺陷 + * 1. **对 wxid_ 开头的目录强制要求"带后缀"**: + * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), + * 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。 + * + * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**: + * 旧实现写法是 + * ``` + * if (!lowerWxid.startsWith('wxid_')) { + * const direct = join(root, cleanedWxid) + * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage + * } + * ``` + * 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成 + * `<自定义号>`,于是无论用户存的是哪个 wxid,都会命中旧的、无后缀的 + * 空目录(它真实存在但里面没有 db_storage),最终触发 -3001。 + * + * ### 修复后的统一匹配流程 + * 1. 扫描 dbPath 下所有子目录; + * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 + * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; + * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项 + * (没有 db_storage 也没有 FileStorage/Image[2]); + * 4. 在剩余候选中按以下优先级排序,取最优: + * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; + * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; + * - **修改时间更新** > 更旧:兜底。 + */ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => { if (!dbPath || !wxid) return null @@ -34,6 +89,7 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null const normalized = dbPath.replace(/[\\/]+$/, '') const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` + // 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除 const cached = accountDirCache.get(cacheKey) if (cached && existsSync(cached)) return cached if (cached && !existsSync(cached)) { @@ -41,16 +97,12 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null } const lowerWxid = cleanedWxid.toLowerCase() - if (!lowerWxid.startsWith('wxid_')) { - const direct = join(normalized, cleanedWxid) - if (existsSync(direct) && isDirectory(direct)) { - accountDirCache.set(cacheKey, direct) - return direct - } - } try { const entries = readdirSync(normalized) + type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } + const candidates: Candidate[] = [] + for (const entry of entries) { const entryPath = join(normalized, entry) if (!isDirectory(entryPath)) continue @@ -58,16 +110,72 @@ export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null const lowerEntry = entry.toLowerCase() const isExactMatch = lowerEntry === lowerWxid const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) - const shouldMatch = lowerWxid.startsWith('wxid_') - ? isSuffixMatch - : (isExactMatch || isSuffixMatch) + // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 + if (!isExactMatch && !isSuffixMatch) continue - if (shouldMatch) { - accountDirCache.set(cacheKey, entryPath) - return entryPath - } + // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 + // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 + // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 + if (!accountDirLooksValid(entryPath)) continue + + let mtime = 0 + try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } + candidates.push({ + entryPath, + isExact: isExactMatch, + hasSession: accountDirHasSessionDb(entryPath), + mtime, + }) } - } catch { } + + if (candidates.length > 0) { + candidates.sort((a, b) => { + // 1) 优先选有 session.db 的(真实写入数据的目录) + if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 + // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) + if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 + // 3) 最后按修改时间倒序(最新的优先) + return b.mtime - a.mtime + }) + const best = candidates[0].entryPath + accountDirCache.set(cacheKey, best) + return best + } + } catch { /* 扫描目录失败时直接 fallthrough 返回 null */ } return null } + +/** + * 浅层判定一个目录"看起来像不像账号目录": + * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 + * + * 用于在候选阶段剔除"同名但实际无数据"的残留空目录 + *(例如自定义微信号后遗留下来的旧 wxid 主目录)。 + */ +const accountDirLooksValid = (entryPath: string): boolean => { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + ) +} + +/** + * 检测账号目录下是否存在 session.db。 + * + * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, + * 同时兼容微信 4.x 两种已知布局: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ +const accountDirHasSessionDb = (entryPath: string): boolean => { + const candidates = [ + join(entryPath, 'db_storage', 'session', 'session.db'), + join(entryPath, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false +} diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts index e26a4a4..feac28e 100644 --- a/electron/services/backupService.ts +++ b/electron/services/backupService.ts @@ -460,6 +460,7 @@ export class BackupService { const dbStorage = join(accountDir, 'db_storage') if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' } + const accountDirName = basename(accountDir) const opened = await withTimeout( wcdbService.open(accountDir, decryptKey), 15000, @@ -467,10 +468,10 @@ export class BackupService { ) if (!opened) { const detail = await wcdbService.getLastInitError().catch(() => null) - return { success: false, error: detail || `目标账号 ${accountDirName} 数据库连接失败` } + return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` } } - return { success: true, wxid: accountDirName, dbPath, dbStorage } + return { success: true, wxid: accountDir, dbPath, dbStorage } } private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string { @@ -857,10 +858,13 @@ export class BackupService { if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' } const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) { + emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' }) return { success: false, error: '不支持的备份包格式' } } + emitBackupProgress({ phase: 'done', message: '备份包已读取' }) return { success: true, manifest } } catch (e) { + emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) }) return { success: false, error: e instanceof Error ? e.message : String(e) } } finally { if (extractDir) { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b827d41..6c393a0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,5 +1,6 @@ import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' +import { createRequire } from 'module' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' @@ -453,7 +454,7 @@ class ChatService { this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string }): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void { this.runtimeConfig = config } @@ -2585,6 +2586,93 @@ class ChatService { } } + async getMessagesAround( + sessionId: string, + target: { localId?: number; createTime: number; messageKey?: string }, + totalContextCount: number = 50 + ): Promise<{ + success: boolean + before: Message[] + after: Message[] + requested: number + error?: string + }> { + const requested = Math.max(1, Math.min(200, Math.floor(Number(totalContextCount) || 50))) + const targetCreateTime = Math.floor(Number(target?.createTime || 0)) + if (!sessionId || targetCreateTime <= 0) { + return { success: false, before: [], after: [], requested, error: '无效的目标消息' } + } + + const collect = async (ascending: boolean): Promise => { + let cursor: number | undefined + try { + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + Math.min(240, Math.max(60, requested + 20)), + ascending, + ascending ? targetCreateTime : 0, + ascending ? 0 : targetCreateTime + 1 + ) + if (!cursorResult.success || !cursorResult.cursor) { + throw new Error(cursorResult.error || '打开消息游标失败') + } + cursor = cursorResult.cursor + const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursor, requested + 1) + if (!collected.success) { + throw new Error(collected.error || '读取上下文消息失败') + } + const targetLocalId = Math.floor(Number(target?.localId || 0)) + const targetMessageKey = String(target?.messageKey || '').trim() + return (collected.messages || []).filter((message) => { + const sameLocalId = targetLocalId > 0 && Number(message.localId || 0) === targetLocalId + const sameCreateTime = Number(message.createTime || 0) === targetCreateTime + const sameKey = Boolean(targetMessageKey && message.messageKey === targetMessageKey) + return !(sameKey || (sameLocalId && sameCreateTime)) + }) + } finally { + if (cursor) { + await wcdbService.closeMessageCursor(cursor).catch(() => {}) + } + } + } + + try { + const [beforeCandidatesRaw, afterCandidatesRaw] = await Promise.all([ + collect(false), + collect(true) + ]) + const beforeCandidates = beforeCandidatesRaw + .filter((message) => Number(message.createTime || 0) <= targetCreateTime) + .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) + const afterCandidates = afterCandidatesRaw + .filter((message) => Number(message.createTime || 0) >= targetCreateTime) + .sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq)) + + const baseBefore = Math.floor(requested / 2) + const baseAfter = requested - baseBefore + const takeAfter = Math.min(baseAfter, afterCandidates.length) + const takeBefore = Math.min(requested - takeAfter, beforeCandidates.length) + const remainingAfter = Math.max(0, requested - takeBefore - takeAfter) + const finalAfter = Math.min(afterCandidates.length, takeAfter + remainingAfter) + const finalBefore = Math.min(beforeCandidates.length, requested - finalAfter) + + return { + success: true, + before: beforeCandidates.slice(Math.max(0, beforeCandidates.length - finalBefore)), + after: afterCandidates.slice(0, finalAfter), + requested + } + } catch (error) { + return { + success: false, + before: [], + after: [], + requested, + error: (error as Error).message || String(error) + } + } + } + async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> { try { const connectResult = await this.ensureConnected() @@ -8613,13 +8701,17 @@ class ChatService { private async decodeSilkToPcm(silkData: Buffer, sampleRate: number): Promise { try { let wasmPath: string - if (app.isPackaged) { - wasmPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + const isPackaged = this.runtimeConfig?.isPackaged ?? app.isPackaged + const resourcesPath = this.runtimeConfig?.resourcesPath ?? process.resourcesPath + const appPath = this.runtimeConfig?.appPath ?? app.getAppPath() + + if (isPackaged) { + wasmPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') if (!existsSync(wasmPath)) { - wasmPath = join(process.resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(resourcesPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } } else { - wasmPath = join(app.getAppPath(), 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') + wasmPath = join(appPath, 'node_modules', 'silk-wasm', 'lib', 'silk.wasm') } if (!existsSync(wasmPath)) { @@ -8627,7 +8719,9 @@ class ChatService { return null } - const silkWasm = require('silk-wasm') + // 在 worker 环境中使用 createRequire 来正确加载模块 + const requireFromApp = createRequire(join(appPath, 'package.json')) + const silkWasm = requireFromApp('silk-wasm') if (!silkWasm || !silkWasm.decode) { console.error('[ChatService][Voice] silk-wasm module invalid') return null @@ -9456,12 +9550,13 @@ class ChatService { data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit) - if (privateSessionIds.length > 0 && data.private_segments.length === 0) { + if (data.private_sessions.length > 0) { + const sessionsWithMessages = data.private_sessions.map(s => s.session_id) const privateSegments = await this.rebuildMyFootprintPrivateSegments({ begin, end: normalizedEnd, myWxid, - privateSessionIds + privateSessionIds: sessionsWithMessages }) if (privateSegments.length > 0) { data = { @@ -9561,7 +9656,7 @@ class ChatService { myWxid: string privateSessionIds: string[] }): Promise { - const sessionGapSeconds = 10 * 60 + const sessionGapSeconds = 5 * 60 const segments: MyFootprintPrivateSegment[] = [] type WorkingSegment = { @@ -9579,14 +9674,17 @@ class ChatService { } for (const sessionId of params.privateSessionIds) { - const cursorResult = await wcdbService.openMessageCursorLite( + const cursorResult = await wcdbService.openMessageCursor( sessionId, 360, true, - params.begin, - params.end + 0, + 0 ) - if (!cursorResult.success || !cursorResult.cursor) continue + if (!cursorResult.success || !cursorResult.cursor) { + console.log(`[足迹分段] 打开游标失败: ${sessionId}, 原因: ${cursorResult.error || '未知'}`) + continue + } let segmentCursor = 0 let active: WorkingSegment | null = null @@ -9620,19 +9718,30 @@ class ChatService { } let hasMore = true + let batchCount = 0 + let totalMessages = 0 try { while (hasMore) { const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + batchCount++ if (!batchResult.success || !Array.isArray(batchResult.rows)) break hasMore = Boolean(batchResult.hasMore) + totalMessages += batchResult.rows.length for (const row of batchResult.rows as Array>) { const createTime = this.toSafeInt(row.create_time, 0) const localId = this.toSafeInt(row.local_id, 0) const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) + // 过滤时间范围外的消息 + if (createTime > 0 && (createTime < params.begin || createTime > params.end)) { + continue + } + if (createTime > 0) { - const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds) + const referenceTs = lastMessageTs > 0 ? lastMessageTs : (active ? active.end_ts : 0) + const timeDiff = referenceTs > 0 ? createTime - referenceTs : 0 + const needNew = !active || (referenceTs > 0 && timeDiff > sessionGapSeconds) if (needNew) { commit() segmentCursor += 1 diff --git a/electron/services/config.ts b/electron/services/config.ts index 618d908..74180a2 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -129,6 +129,14 @@ interface ConfigSchema { // AI 足迹 aiFootprintEnabled: boolean aiFootprintSystemPrompt: string + aiGroupSummaryEnabled: boolean + aiGroupSummaryIntervalHours: number + aiGroupSummarySystemPrompt: string + aiGroupSummaryFilterMode: 'whitelist' | 'blacklist' + aiGroupSummaryFilterList: string[] + aiMessageInsightEnabled: boolean + aiMessageInsightContextCount: number + aiMessageInsightSystemPrompt: string /** 是否将 AI 见解调试日志输出到桌面 */ aiInsightDebugLogEnabled: boolean autoDownloadHighRes: boolean @@ -252,6 +260,14 @@ export class ConfigService { aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', + aiGroupSummaryEnabled: false, + aiGroupSummaryIntervalHours: 4, + aiGroupSummarySystemPrompt: '', + aiGroupSummaryFilterMode: 'whitelist', + aiGroupSummaryFilterList: [], + aiMessageInsightEnabled: false, + aiMessageInsightContextCount: 50, + aiMessageInsightSystemPrompt: '', aiInsightDebugLogEnabled: false, autoDownloadHighRes: false, autoDownloadWhitelist: [] @@ -817,6 +833,12 @@ export class ConfigService { if (!sharedModel && legacyModel) { this.set('aiModelApiModel', legacyModel) } + + const groupSummaryFilterMode = String(this.store.get('aiGroupSummaryFilterMode' as any) || '').trim() + if (groupSummaryFilterMode === 'blacklist') { + this.store.set('aiGroupSummaryFilterList' as any, [] as any) + this.store.set('aiGroupSummaryFilterMode' as any, 'whitelist' as any) + } } // === 验证 === @@ -899,12 +921,78 @@ export class ConfigService { } /** - * 获取账号目录路径 - * 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现 + * 浅层判定一个目录"看起来像不像账号目录": + * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。 * - * @param dbPath 数据库根目录(可选,默认从配置读取) - * @param wxid 微信ID(可选,默认从配置读取) - * @returns 账号目录的完整路径,如果找不到返回 null + * 用于在 {@link getAccountDir} 候选阶段剔除"同名但实际无数据"的残留空目录 + * (例如自定义微信号后微信遗留下来的旧 wxid 主目录)。 + */ + private accountDirLooksValid(entryPath: string): boolean { + return ( + existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + ) + } + + /** + * 检测账号目录下是否存在 session.db。 + * + * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据, + * 同时兼容微信 4.x 两种已知布局: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ + private accountDirHasSessionDb(entryPath: string): boolean { + const candidates = [ + join(entryPath, 'db_storage', 'session', 'session.db'), + join(entryPath, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false + } + + /** + * 获取账号目录的真实绝对路径。 + * + * 这是 WeFlow 统一的账号目录解析入口,所有服务都应通过本方法获取 + * 账号目录,而不要自行拼接 `join(dbPath, wxid)`。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现存在的两处严重缺陷 + * 1. **对 wxid_ 开头强制要求"带后缀"**: + * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀), + * 旧逻辑把它过滤掉,导致这类用户根本匹配不到自己的账号目录。 + * + * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,不校验目录有效性**: + * 旧实现写法是 + * ```ts + * if (!lowerWxid.startsWith('wxid_')) { + * const direct = join(root, cleanedWxid) + * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage + * } + * ``` + * 叠加 {@link cleanAccountDirName} 会把 `<自定义号>_<4位后缀>` 清洗成 + * `<自定义号>`,于是无论用户保存的是哪个 wxid,都会命中旧的、 + * 无后缀的空目录(它真实存在但里面没有 db_storage),最终在 + * wcdbCore.open 阶段触发 -3001。 + * + * ### 修复后的统一匹配流程 + * 1. 扫描 dbPath 下所有子目录; + * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与 + * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式; + * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项; + * 4. 在剩余候选中按以下优先级排序,取最优: + * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录"; + * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致; + * - **修改时间更新** > 更旧:兜底。 + * + * @param dbPath 数据库根目录(可选,默认从配置读取 `dbPath`) + * @param wxid 微信 ID(可选,默认从配置读取 `myWxid`) + * @returns 账号目录的完整绝对路径;找不到返回 null */ getAccountDir(dbPath?: string, wxid?: string): string | null { const actualDbPath = dbPath || this.get('dbPath') @@ -916,26 +1004,20 @@ export class ConfigService { 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) + type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number } + const candidates: Candidate[] = [] + for (const entry of entries) { const entryPath = join(normalized, entry) if (!this.isDirectory(entryPath)) continue @@ -943,16 +1025,36 @@ export class ConfigService { const lowerEntry = entry.toLowerCase() const isExactMatch = lowerEntry === lowerWxid const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`) + // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过 + if (!isExactMatch && !isSuffixMatch) continue - // wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以 - const shouldMatch = lowerWxid.startsWith('wxid_') - ? isSuffixMatch - : (isExactMatch || isSuffixMatch) + // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过 + // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录 + // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。 + if (!this.accountDirLooksValid(entryPath)) continue - if (shouldMatch) { - this.accountDirCache.set(cacheKey, entryPath) - return entryPath - } + let mtime = 0 + try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ } + candidates.push({ + entryPath, + isExact: isExactMatch, + hasSession: this.accountDirHasSessionDb(entryPath), + mtime, + }) + } + + if (candidates.length > 0) { + candidates.sort((a, b) => { + // 1) 优先选有 session.db 的(真实写入数据的目录) + if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1 + // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录) + if (a.isExact !== b.isExact) return a.isExact ? 1 : -1 + // 3) 最后按修改时间倒序(最新的优先) + return b.mtime - a.mtime + }) + const best = candidates[0].entryPath + this.accountDirCache.set(cacheKey, best) + return best } } catch { } diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ad92985..9e8de33 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -137,7 +137,25 @@ export class DbPathService { } /** - * 查找账号目录(包含 db_storage 或图片目录) + * 查找 dbPath 根目录下所有"看起来像账号目录"的子目录名。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * ### 旧实现的过滤逻辑及缺陷 + * 旧实现对名字以 `wxid_` 开头的目录额外加了一道判断: + * "段数(按下划线切分)必须 ≥ 3,否则跳过" + * 也就是 `wxid_X_` 才算合法、`wxid_X` 一律忽略。 + * + * 这种粗暴过滤会**误伤未自定义微信号的普通用户**——他们的真实账号目录 + * 就叫 `wxid_X`(没有任何数字后缀),结果在欢迎页扫描时压根看不到自己。 + * + * ### 修复策略 + * 1. **不再依据"段数"过滤**:先按是否真的是账号目录(含 db_storage 或 + * FileStorage/Image[2])一视同仁地收集所有候选; + * 2. **用 {@link dedupeAccountDirs} 做更精准的去重**:仅当 `wxid_X` 和 + * `wxid_X_` 同时存在时(这是自定义微信号后微信遗留旧空目录 + * 的典型场景),才二选一保留"更像微信实际在用"的那个,避免下拉框里 + * 出现两个看起来一样但只有一个能用的混乱选项。 */ findAccountDirs(rootPath: string): string[] { const resolvedRootPath = expandHomePath(rootPath) @@ -160,23 +178,93 @@ export class DbPathService { // 检查是否有有效账号目录结构 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) } } } } catch { } - return accounts + return this.dedupeAccountDirs(resolvedRootPath, accounts) + } + + /** + * 账号目录去重:仅当存在"前缀-后缀变体对"时(即同时出现 `wxid_X` 与 + * `wxid_X_`),才二选一保留"微信实际在用"的那个目录。 + * + * - 仅有一个候选目录时,原样返回,不做任何处理; + * - 没有匹配到变体对的目录也都保留(互不相关的多账号场景); + * - 真正二选一时由 {@link shouldPreferSuffixedDir} 决定胜负。 + */ + private dedupeAccountDirs(rootPath: string, names: string[]): string[] { + if (names.length <= 1) return names.slice() + + const lowered = names.map(n => n.toLowerCase()) + const toSkip = new Set() + + // O(n^2) 双层循环找出所有"前缀-后缀变体对"。账号数极少,性能可忽略。 + for (let i = 0; i < names.length; i++) { + for (let j = 0; j < names.length; j++) { + if (i === j) continue + // 判定 names[j] 是 names[i] 的"带后缀变体":以 `_` 开头 + if (lowered[j].startsWith(lowered[i] + '_')) { + const baseName = names[i] + const suffixedName = names[j] + if (this.shouldPreferSuffixedDir(rootPath, baseName, suffixedName)) { + toSkip.add(baseName) // 留 suffixedName,去掉无后缀的旧目录 + } else { + toSkip.add(suffixedName) // 反之亦然 + } + } + } + } + + return names.filter(n => !toSkip.has(n)) + } + + /** + * 在"无后缀目录"与"带后缀目录"之间二选一时,判定后者是否应该胜出。 + * + * 优先级(从高到低): + * 1) 谁含有 session.db 谁优先 —— 这是"数据真实写入"最强的信号; + * 2) 都含或都不含 session.db 时,比较修改时间,更新的优先; + * 3) 兜底返回 true,即默认保留带后缀的目录(与微信 4.x 自定义微信号 + * 后真实目录命名一致)。 + */ + private shouldPreferSuffixedDir(rootPath: string, baseName: string, suffixedName: string): boolean { + const basePath = join(rootPath, baseName) + const suffixedPath = join(rootPath, suffixedName) + + const baseHasSession = this.hasSessionDb(basePath) + const suffixedHasSession = this.hasSessionDb(suffixedPath) + if (baseHasSession !== suffixedHasSession) { + return suffixedHasSession + } + + const baseTime = this.getAccountModifiedTime(basePath) + const suffixedTime = this.getAccountModifiedTime(suffixedPath) + if (baseTime !== suffixedTime) { + return suffixedTime >= baseTime + } + + return true + } + + /** + * 浅层检测账号目录下是否存在 session.db("数据是否真实写入"的判据)。 + * + * 仅检测两条已知路径,不做深度递归,避免在大目录上拖慢扫描: + * - db_storage/session/session.db (新版本嵌套布局) + * - db_storage/session.db (部分版本扁平布局) + */ + private hasSessionDb(accountDir: string): boolean { + const candidates = [ + join(accountDir, 'db_storage', 'session', 'session.db'), + join(accountDir, 'db_storage', 'session.db'), + ] + for (const candidate of candidates) { + if (existsSync(candidate)) return true + } + return false } private isAccountDir(entryPath: string): boolean { @@ -225,7 +313,20 @@ export class DbPathService { } /** - * 扫描目录名候选(仅包含下划线的文件夹,排除 all_users) + * 扫描 dbPath 下"目录名包含下划线"的文件夹作为 wxid 候选。 + * 与 {@link findAccountDirs} 的区别:本方法不要求目录里真的有 db_storage/ + * FileStorage,仅按命名特征判断,结果会暴露给"手动选择 wxid"的弹窗使用。 + * + * ## 修复 #996(错误码 -3001:未找到数据库目录) + * + * 旧实现对 `wxid_` 开头的目录额外要求"段数 ≥ 3"才放行,会误伤未自定义 + * 微信号的普通用户(他们的真实目录就叫 `wxid_X`)。现在改为不再依据段数 + * 过滤,并在末尾通过 {@link dedupeAccountDirs} 处理 `wxid_X` 与 + * `wxid_X_` 同时存在的去重场景。 + * + * 排除规则保留: + * - 微信本身的非账号目录(如 `all_users`); + * - 不含下划线的文件夹(不可能是 wxid)。 */ scanWxidCandidates(rootPath: string): WxidInfo[] { const resolvedRootPath = expandHomePath(rootPath) @@ -243,15 +344,6 @@ export class DbPathService { if (lower === 'all_users') 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 }) } } @@ -266,7 +358,13 @@ export class DbPathService { } } catch { } - const sorted = wxids.sort((a, b) => { + // 修复 #996:对扫描到的 wxid 候选做去重,避免同时显示 wxid_X 与 wxid_X_。 + const dedupedNames = new Set( + this.dedupeAccountDirs(resolvedRootPath, wxids.map(w => w.wxid)) + ) + const deduped = wxids.filter(w => dedupedNames.has(w.wxid)) + + const sorted = deduped.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) }); diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 72198df..cd88bb0 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -323,7 +323,7 @@ class ExportService { return error } - setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string } | null): void { + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; imageXorKey?: unknown; imageAesKey?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean } | null): void { this.runtimeConfig = config imageDecryptService.setRuntimeConfig({ dbPath: config?.dbPath, @@ -331,6 +331,14 @@ class ExportService { imageXorKey: config?.imageXorKey, imageAesKey: config?.imageAesKey }) + chatService.setRuntimeConfig({ + dbPath: config?.dbPath, + decryptKey: config?.decryptKey, + myWxid: config?.myWxid, + resourcesPath: config?.resourcesPath, + appPath: config?.appPath, + isPackaged: config?.isPackaged + }) } private getConfiguredDbPath(): string { @@ -6651,7 +6659,7 @@ class ExportService { if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' - } else if (mediaItem && msg.localType === 3) { + } else if (mediaItem && msg.localType !== 47) { content = mediaItem.relativePath } else { content = this.parseMessageContent( diff --git a/electron/services/groupSummaryRecordService.ts b/electron/services/groupSummaryRecordService.ts new file mode 100644 index 0000000..5cadced --- /dev/null +++ b/electron/services/groupSummaryRecordService.ts @@ -0,0 +1,384 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' +import { createHash, randomUUID } from 'crypto' +import { ConfigService } from './config' + +export type GroupSummaryTriggerType = 'auto' | 'manual' + +export interface GroupSummaryTopic { + title: string + participants: string[] + keyPoints: string[] + conclusion: string +} + +export interface GroupSummaryLog { + endpoint: string + model: string + temperature: number + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + systemPrompt: string + userPrompt: string + rawOutput: string + finalSummary: string + durationMs: number + createdAt: number + responseFormatJson?: boolean + responseFormatFallback?: boolean + responseFormatFallbackReason?: string + parsedTopics?: GroupSummaryTopic[] +} + +export interface GroupSummaryRecord { + id: string + accountScope: string + createdAt: number + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string + rawOutput: string + log: GroupSummaryLog +} + +export interface GroupSummaryRecordSummary { + id: string + createdAt: number + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string +} + +export interface GroupSummaryRecordFilters { + sessionId?: string + startTime?: number + endTime?: number + limit?: number + offset?: number +} + +export interface GroupSummaryRecordListResult { + success: boolean + records: GroupSummaryRecordSummary[] + total: number + error?: string +} + +interface GroupSummaryIndexRecord extends GroupSummaryRecordSummary { + accountScope: string + logFile?: string +} + +interface LegacyGroupSummaryRecord extends GroupSummaryIndexRecord { + rawOutput?: string + log?: GroupSummaryLog +} + +class GroupSummaryRecordService { + private readonly maxRecordsPerScope = 2000 + private filePath: string | null = null + private logDir: string | null = null + private loaded = false + private records: GroupSummaryIndexRecord[] = [] + + private resolveUserDataPath(): string { + 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 }) + return userDataPath + } + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + this.filePath = path.join(this.resolveUserDataPath(), 'weflow-group-summary-records.json') + return this.filePath + } + + private resolveLogDir(): string { + if (this.logDir) return this.logDir + this.logDir = path.join(this.resolveUserDataPath(), 'weflow-group-summary-logs') + fs.mkdirSync(this.logDir, { recursive: true }) + return this.logDir + } + + private normalizeTimestampSeconds(value: unknown): number { + const numeric = Number(value || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + let normalized = Math.floor(numeric) + while (normalized > 10000000000) { + normalized = Math.floor(normalized / 1000) + } + return normalized + } + + private safeLogFileName(id: string): string { + const normalized = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '') + return `${normalized || randomUUID()}.json` + } + + private writeLogFile(recordId: string, log: GroupSummaryLog, rawOutput: string): string | undefined { + try { + const fileName = this.safeLogFileName(recordId) + const logPath = path.join(this.resolveLogDir(), fileName) + fs.writeFileSync(logPath, JSON.stringify({ version: 1, rawOutput, log }, null, 2), 'utf-8') + return fileName + } catch { + return undefined + } + } + + private readLogFile(fileName?: string): { rawOutput: string; log: GroupSummaryLog } | null { + if (!fileName) return null + try { + const logPath = path.join(this.resolveLogDir(), this.safeLogFileName(fileName.replace(/\.json$/i, ''))) + if (!fs.existsSync(logPath)) return null + const parsed = JSON.parse(fs.readFileSync(logPath, 'utf-8')) + const log = parsed?.log + if (!log || typeof log !== 'object') return null + return { + rawOutput: typeof parsed?.rawOutput === 'string' ? parsed.rawOutput : String(log.rawOutput || ''), + log: log as GroupSummaryLog + } + } catch { + return null + } + } + + 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) + const records = Array.isArray(parsed) ? parsed : parsed?.records + if (!Array.isArray(records)) return + + const legacyRecords = records.filter((item) => item && typeof item === 'object') as LegacyGroupSummaryRecord[] + const needsMigration = legacyRecords.some((record) => Boolean(record.log || record.rawOutput)) + if (needsMigration) { + this.backupLegacyFile(filePath) + } + + this.records = legacyRecords.map((record) => { + const id = String(record.id || randomUUID()) + const logFile = record.log + ? this.writeLogFile(id, record.log, String(record.rawOutput || record.log.rawOutput || '')) + : record.logFile + return { + id, + accountScope: String(record.accountScope || 'default'), + createdAt: Number(record.createdAt || Date.now()), + sessionId: String(record.sessionId || ''), + displayName: String(record.displayName || record.sessionId || ''), + avatarUrl: record.avatarUrl, + triggerType: record.triggerType === 'auto' ? 'auto' : 'manual', + periodStart: this.normalizeTimestampSeconds(record.periodStart), + periodEnd: this.normalizeTimestampSeconds(record.periodEnd), + messageCount: Math.max(0, Math.floor(Number(record.messageCount || 0))), + readableMessageCount: Math.max(0, Math.floor(Number(record.readableMessageCount || 0))), + topics: Array.isArray(record.topics) ? record.topics : [], + summaryText: String(record.summaryText || ''), + logFile + } + }).filter((record) => record.sessionId && record.periodStart > 0 && record.periodEnd > record.periodStart) + + if (needsMigration) { + this.persist() + } + } catch { + this.records = [] + } + } + + private backupLegacyFile(filePath: string): void { + try { + const backupPath = `${filePath}.legacy-${Date.now()}.bak` + if (!fs.existsSync(backupPath)) { + fs.copyFileSync(filePath, backupPath) + } + } catch { + // Backup failure should not block reading existing records. + } + } + + private persist(): void { + try { + const filePath = this.resolveFilePath() + fs.writeFileSync(filePath, JSON.stringify({ version: 2, records: this.records }, null, 2), 'utf-8') + } catch { + // Summary generation should not fail because local record persistence failed. + } + } + + private getCurrentAccountScope(): string { + const config = ConfigService.getInstance() + const myWxid = String(config.getMyWxidCleaned() || '').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 toSummary(record: GroupSummaryIndexRecord): GroupSummaryRecordSummary { + return { + id: record.id, + createdAt: record.createdAt, + sessionId: record.sessionId, + displayName: record.displayName, + avatarUrl: record.avatarUrl, + triggerType: record.triggerType, + periodStart: record.periodStart, + periodEnd: record.periodEnd, + messageCount: record.messageCount, + readableMessageCount: record.readableMessageCount, + topics: Array.isArray(record.topics) ? record.topics : [], + summaryText: record.summaryText || '' + } + } + + private getScopedRecords(): GroupSummaryIndexRecord[] { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + return this.records.filter((record) => record.accountScope === scope) + } + + addRecord(input: { + sessionId: string + displayName: string + avatarUrl?: string + triggerType: GroupSummaryTriggerType + periodStart: number + periodEnd: number + messageCount: number + readableMessageCount: number + topics: GroupSummaryTopic[] + summaryText: string + rawOutput: string + log: GroupSummaryLog + }): GroupSummaryRecordSummary { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const id = randomUUID() + const logFile = this.writeLogFile(id, input.log, input.rawOutput) + const record: GroupSummaryIndexRecord = { + id, + accountScope: scope, + createdAt: Date.now(), + sessionId: input.sessionId, + displayName: input.displayName, + avatarUrl: input.avatarUrl, + triggerType: input.triggerType, + periodStart: input.periodStart, + periodEnd: input.periodEnd, + messageCount: input.messageCount, + readableMessageCount: input.readableMessageCount, + topics: input.topics, + summaryText: input.summaryText, + logFile + } + + 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 this.toSummary(record) + } + + hasAutoRecord(sessionId: string, periodStart: number, periodEnd: number): boolean { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + return this.getScopedRecords().some((record) => + record.triggerType === 'auto' && + record.sessionId === normalizedSessionId && + Number(record.periodStart || 0) === periodStart && + Number(record.periodEnd || 0) === periodEnd + ) + } + + listRecords(filters: GroupSummaryRecordFilters = {}): GroupSummaryRecordListResult { + try { + const sessionId = String(filters.sessionId || '').trim() + const startTime = this.normalizeTimestampSeconds(filters.startTime) + const endTime = this.normalizeTimestampSeconds(filters.endTime) + 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 = this.getScopedRecords() + .filter((record) => { + if (sessionId && record.sessionId !== sessionId) return false + const periodStart = Number(record.periodStart || 0) + const periodEnd = Number(record.periodEnd || 0) + if (startTime > 0 && periodEnd < startTime) return false + if (endTime > 0 && periodStart > endTime) return false + return true + }) + .sort((a, b) => Number(b.periodStart || b.createdAt) - Number(a.periodStart || a.createdAt)) + + return { + success: true, + records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)), + total: filtered.length + } + } catch (error) { + return { success: false, records: [], total: 0, error: (error as Error).message || String(error) } + } + } + + getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; 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: '未找到该群聊总结记录' } + + const logData = this.readLogFile(record.logFile) + if (!logData) return { success: false, error: '未找到该群聊总结日志' } + + return { + success: true, + record: { + ...this.toSummary(record), + accountScope: record.accountScope, + rawOutput: logData.rawOutput, + log: logData.log + } + } + } + + clearRuntimeCache(): void { + this.loaded = false + this.records = [] + this.filePath = null + this.logDir = null + } +} + +export const groupSummaryRecordService = new GroupSummaryRecordService() diff --git a/electron/services/groupSummaryService.ts b/electron/services/groupSummaryService.ts new file mode 100644 index 0000000..8eb4200 --- /dev/null +++ b/electron/services/groupSummaryService.ts @@ -0,0 +1,801 @@ +import https from 'https' +import http from 'http' +import { URL } from 'url' +import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json' +import { ConfigService } from './config' +import { chatService, type Message } from './chatService' +import { wcdbService } from './wcdbService' +import { + groupSummaryRecordService, + type GroupSummaryLog, + type GroupSummaryRecord, + type GroupSummaryRecordFilters, + type GroupSummaryRecordListResult, + type GroupSummaryRecordSummary, + type GroupSummaryTopic, + type GroupSummaryTriggerType +} from './groupSummaryRecordService' + +const API_TIMEOUT_MS = 90_000 +const API_TEMPERATURE = 0.4 +const MIN_SUMMARY_MESSAGES = 5 +const MAX_MANUAL_RANGE_SECONDS = 48 * 60 * 60 +const MAX_MESSAGES_PER_SUMMARY = 3000 +const SUMMARY_CURSOR_BATCH_SIZE = 360 +const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim() +const SUMMARY_CONFIG_KEYS = new Set([ + 'aiGroupSummaryEnabled', + 'aiGroupSummaryIntervalHours', + 'aiGroupSummarySystemPrompt', + 'aiGroupSummaryFilterMode', + 'aiGroupSummaryFilterList', + 'aiModelApiBaseUrl', + 'aiModelApiKey', + 'aiModelApiModel', + 'aiInsightApiBaseUrl', + 'aiInsightApiKey', + 'aiInsightApiModel', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +interface SharedAiModelConfig { + apiBaseUrl: string + apiKey: string + model: string +} + +interface GroupSummaryTriggerResult { + success: boolean + message: string + recordId?: string + record?: GroupSummaryRecordSummary + skipped?: boolean + skippedReason?: string +} + +interface GroupSummaryDayTriggerResult { + success: boolean + message: string + generated: number + skipped: number + records: GroupSummaryRecordSummary[] +} + +class ApiRequestError extends Error { + statusCode?: number + responseBody?: string + + constructor(message: string, statusCode?: number, responseBody?: string) { + super(message) + this.name = 'ApiRequestError' + this.statusCode = statusCode + this.responseBody = responseBody + } +} + +function buildApiUrl(baseUrl: string, path: string): string { + const base = baseUrl.replace(/\/+$/, '') + const suffix = path.startsWith('/') ? path : `/${path}` + return `${base}${suffix}` +} + +function normalizeSessionIdList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + +function normalizeIntervalHours(value: unknown): number { + const allowed = new Set([1, 2, 4, 8, 12, 24]) + const numeric = Math.floor(Number(value) || 4) + return allowed.has(numeric) ? numeric : 4 +} + +function getStartOfDaySeconds(date: Date = new Date()): number { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return Math.floor(next.getTime() / 1000) +} + +function clampText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\s+/g, ' ').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` +} + +function stripJsonFence(value: string): string { + const text = String(value || '').trim() + const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced) return fenced[1].trim() + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + return text.slice(firstBrace, lastBrace + 1).trim() + } + return text +} + +function shouldFallbackJsonMode(error: unknown): boolean { + const statusCode = (error as ApiRequestError)?.statusCode + if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true + const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() + return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') +} + +function formatTimestamp(createTime: number): string { + const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000 + const date = new Date(ms) + 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 callChatCompletions( + apiBaseUrl: string, + apiKey: string, + model: string, + messages: Array<{ role: string; content: string }>, + options?: { responseFormatJson?: boolean } +): Promise { + return new Promise((resolve, reject) => { + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + let urlObj: URL + try { + urlObj = new URL(endpoint) + } catch { + reject(new Error(`无效的 API URL: ${endpoint}`)) + return + } + + const payload: Record = { + model, + messages, + temperature: API_TEMPERATURE, + stream: false + } + if (options?.responseFormatJson) { + payload.response_format = { type: 'json_object' } + } + + const body = JSON.stringify(payload) + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST' as const, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + Authorization: `Bearer ${apiKey}` + } + } + + const requestFn = urlObj.protocol === 'https:' ? https.request : http.request + const req = requestFn(requestOptions, (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => { + try { + if (res.statusCode && res.statusCode >= 400) { + reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) + return + } + const parsed = JSON.parse(data) + const content = parsed?.choices?.[0]?.message?.content + if (typeof content === 'string' && content.trim()) { + resolve(content.trim()) + } else { + reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) + } + } catch { + reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) + } + }) + }) + + req.setTimeout(API_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('API 请求超时')) + }) + req.on('error', reject) + req.write(body) + req.end() + }) +} + +function parseTopics(rawOutput: string): GroupSummaryTopic[] { + const parsed = JSON.parse(stripJsonFence(rawOutput)) as unknown + if (!parsed || typeof parsed !== 'object') { + throw new Error('模型输出格式异常:JSON 根节点不是对象') + } + const source = parsed as Record + const rawTopics = Array.isArray(source.topics) ? source.topics : [] + const topics = rawTopics.map((item, index) => { + const topic = item && typeof item === 'object' ? item as Record : {} + const participantsRaw = Array.isArray(topic.participants) ? topic.participants : [] + const keyPointsRaw = Array.isArray(topic.key_points) + ? topic.key_points + : (Array.isArray(topic.keyPoints) ? topic.keyPoints : []) + return { + title: clampText(topic.title || `话题 ${index + 1}`, 48) || `话题 ${index + 1}`, + participants: participantsRaw.map((value) => clampText(value, 24)).filter(Boolean).slice(0, 12), + keyPoints: keyPointsRaw.map((value) => clampText(value, 120)).filter(Boolean).slice(0, 8), + conclusion: clampText(topic.conclusion, 180) || '无明确结论' + } + }).filter((topic) => topic.title || topic.keyPoints.length > 0 || topic.conclusion) + + if (topics.length === 0) { + throw new Error('模型输出格式异常:topics 为空') + } + return topics +} + +function buildSummaryText(topics: GroupSummaryTopic[]): string { + return topics.map((topic) => { + const participants = topic.participants.length > 0 ? topic.participants.join('、') : '未明确' + const keyPoints = topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无' + return `【${topic.title}】参与者:${participants}。关键/矛盾点:${keyPoints}。结论:${topic.conclusion}` + }).join('\n') +} + +function fallbackTopicFromRaw(rawOutput: string): GroupSummaryTopic { + return { + title: '未归类总结', + participants: [], + keyPoints: [clampText(rawOutput, 500)], + conclusion: '模型未按固定 JSON 格式返回,请查看完整日志。' + } +} + +class GroupSummaryService { + private config: ConfigService + private started = false + private scanTimer: NodeJS.Timeout | null = null + private processing = false + private pendingAutoRun = false + private dbConnected = false + + constructor() { + this.config = ConfigService.getInstance() + } + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + stop(): void { + this.started = false + this.clearTimers() + this.processing = false + this.pendingAutoRun = false + this.dbConnected = false + } + + async handleConfigChanged(key: string): Promise { + const normalizedKey = String(key || '').trim() + if (!SUMMARY_CONFIG_KEYS.has(normalizedKey)) return + if (normalizedKey === 'aiGroupSummarySystemPrompt') return + if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { + this.dbConnected = false + groupSummaryRecordService.clearRuntimeCache() + } + await this.refreshConfiguration(`config:${normalizedKey}`) + } + + handleConfigCleared(): void { + this.clearTimers() + this.processing = false + this.pendingAutoRun = false + this.dbConnected = false + groupSummaryRecordService.clearRuntimeCache() + } + + listRecords(filters?: GroupSummaryRecordFilters): GroupSummaryRecordListResult { + return groupSummaryRecordService.listRecords(filters || {}) + } + + getRecord(id: string): { success: boolean; record?: GroupSummaryRecord; error?: string } { + return groupSummaryRecordService.getRecord(id) + } + + async triggerManual(params: { + sessionId: string + displayName?: string + avatarUrl?: string + startTime: number + endTime: number + }): Promise { + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 群聊总结」' } + } + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId.endsWith('@chatroom')) { + return { success: false, message: 'AI 群聊总结仅支持群聊' } + } + const startTime = this.normalizeTimestampSeconds(params?.startTime) + const endTime = this.normalizeTimestampSeconds(params?.endTime) + if (startTime <= 0 || endTime <= startTime) { + return { success: false, message: '请选择有效的总结时段' } + } + if (endTime - startTime > MAX_MANUAL_RANGE_SECONDS) { + return { success: false, message: '手动总结时段不能超过 48 小时' } + } + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + const avatarUrl = String(params?.avatarUrl || '').trim() || undefined + return this.generateSummaryForPeriod({ + sessionId, + displayName, + avatarUrl, + periodStart: startTime, + periodEnd: endTime, + triggerType: 'manual' + }) + } + + async triggerDay(params: { + sessionId: string + displayName?: string + avatarUrl?: string + date: string + }): Promise { + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 群聊总结」', generated: 0, skipped: 0, records: [] } + } + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId.endsWith('@chatroom')) { + return { success: false, message: 'AI 群聊总结仅支持群聊', generated: 0, skipped: 0, records: [] } + } + const dayRange = this.parseLocalDateDayRange(params?.date) + if (!dayRange) { + return { success: false, message: '请选择有效日期', generated: 0, skipped: 0, records: [] } + } + const todayStart = getStartOfDaySeconds(new Date()) + if (dayRange.start > todayStart) { + return { success: false, message: '不能总结未来日期', generated: 0, skipped: 0, records: [] } + } + + const now = Math.floor(Date.now() / 1000) + const effectiveEnd = dayRange.start === todayStart ? Math.min(dayRange.end, now) : dayRange.end + const periods = this.getIntervalPeriods(dayRange.start, effectiveEnd, false) + if (periods.length === 0) { + return { success: true, message: '当前日期暂无已完成的总结时段', generated: 0, skipped: 0, records: [] } + } + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + const avatarUrl = String(params?.avatarUrl || '').trim() || undefined + return this.generateSummariesForPeriods({ + sessionId, + displayName, + avatarUrl, + periods, + triggerType: 'manual' + }) + } + + private async refreshConfiguration(_reason: string): Promise { + if (!this.started) return + this.clearTimers() + if (!this.isEnabled()) return + await this.queueDueAutoSummaries() + this.scheduleNextAutoRun() + } + + private isEnabled(): boolean { + return this.config.get('aiGroupSummaryEnabled') === true + } + + private clearTimers(): void { + if (this.scanTimer !== null) { + clearTimeout(this.scanTimer) + this.scanTimer = null + } + } + + private scheduleNextAutoRun(): void { + if (!this.started || !this.isEnabled()) return + const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) + const now = Math.floor(Date.now() / 1000) + const dayStart = getStartOfDaySeconds(new Date()) + const intervalSeconds = intervalHours * 60 * 60 + const elapsed = Math.max(0, now - dayStart) + const nextBoundary = dayStart + (Math.floor(elapsed / intervalSeconds) + 1) * intervalSeconds + const delayMs = Math.max(1_000, (nextBoundary - now) * 1000 + 1_000) + + this.scanTimer = setTimeout(async () => { + this.scanTimer = null + await this.queueDueAutoSummaries() + this.scheduleNextAutoRun() + }, delayMs) + } + + private async ensureConnected(): Promise { + if (this.dbConnected) return true + const result = await chatService.connect() + this.dbConnected = result.success === true + return this.dbConnected + } + + private getSharedAiModelConfig(): SharedAiModelConfig { + const apiBaseUrl = String( + this.config.get('aiModelApiBaseUrl') + || this.config.get('aiInsightApiBaseUrl') + || '' + ).trim() + const apiKey = String( + this.config.get('aiModelApiKey') + || this.config.get('aiInsightApiKey') + || '' + ).trim() + const model = String( + this.config.get('aiModelApiModel') + || this.config.get('aiInsightApiModel') + || 'gpt-4o-mini' + ).trim() || 'gpt-4o-mini' + return { apiBaseUrl, apiKey, model } + } + + private getAutoScopeSessionIds(): string[] { + return normalizeSessionIdList(this.config.get('aiGroupSummaryFilterList')) + .filter((sessionId) => sessionId.endsWith('@chatroom')) + } + + private normalizeTimestampSeconds(value: unknown): number { + const numeric = Number(value || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + let normalized = Math.floor(numeric) + while (normalized > 10000000000) { + normalized = Math.floor(normalized / 1000) + } + return normalized + } + + private parseLocalDateDayRange(value: unknown): { start: number; end: number } | null { + const text = String(value || '').trim() + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return null + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const start = new Date(year, month - 1, day, 0, 0, 0, 0) + if ( + !Number.isFinite(start.getTime()) || + start.getFullYear() !== year || + start.getMonth() !== month - 1 || + start.getDate() !== day + ) { + return null + } + const end = new Date(start) + end.setDate(end.getDate() + 1) + return { + start: Math.floor(start.getTime() / 1000), + end: Math.floor(end.getTime() / 1000) + } + } + + private getIntervalPeriods(startTime: number, endTime: number, includePartial: boolean): Array<{ start: number; end: number }> { + const intervalHours = normalizeIntervalHours(this.config.get('aiGroupSummaryIntervalHours')) + const intervalSeconds = intervalHours * 60 * 60 + const periods: Array<{ start: number; end: number }> = [] + for (let start = startTime; start < endTime; start += intervalSeconds) { + const end = Math.min(start + intervalSeconds, endTime) + if (!includePartial && end - start < intervalSeconds) continue + if (end > start) periods.push({ start, end }) + } + return periods + } + + private getCompletedPeriodsToday(): Array<{ start: number; end: number }> { + const dayStart = getStartOfDaySeconds(new Date()) + const now = Math.floor(Date.now() / 1000) + return this.getIntervalPeriods(dayStart, now, false) + } + + private async queueDueAutoSummaries(): Promise { + if (!this.started || !this.isEnabled()) return + if (this.processing) { + this.pendingAutoRun = true + return + } + this.processing = true + try { + do { + this.pendingAutoRun = false + await this.runDueAutoSummariesOnce() + } while (this.pendingAutoRun && this.started && this.isEnabled()) + } finally { + this.processing = false + } + } + + private async runDueAutoSummariesOnce(): Promise { + if (!this.started || !this.isEnabled()) return + try { + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) return + const scopeSessionIds = this.getAutoScopeSessionIds() + if (scopeSessionIds.length === 0) return + if (!await this.ensureConnected()) return + + const contacts = (await chatService.enrichSessionsContactInfo(scopeSessionIds).catch(() => null))?.contacts || {} + + const periods = this.getCompletedPeriodsToday() + for (const period of periods) { + for (const sessionId of scopeSessionIds) { + if (!this.started || !this.isEnabled()) return + if (!sessionId) continue + if (groupSummaryRecordService.hasAutoRecord(sessionId, period.start, period.end)) continue + await this.generateSummaryForPeriod({ + sessionId, + displayName: contacts[sessionId]?.displayName || sessionId, + avatarUrl: contacts[sessionId]?.avatarUrl, + periodStart: period.start, + periodEnd: period.end, + triggerType: 'auto' + }) + } + } + } catch (error) { + console.warn('[GroupSummaryService] 自动总结失败:', error) + } + } + + private async readMessagesInPeriod(sessionId: string, startTime: number, endTime: number): Promise { + if (!await this.ensureConnected()) { + throw new Error('数据库连接失败,请先在“数据库连接”页完成配置') + } + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + SUMMARY_CURSOR_BATCH_SIZE, + true, + startTime, + endTime + ) + if (!cursorResult.success || !cursorResult.cursor) { + throw new Error(cursorResult.error || '打开消息游标失败') + } + + const cursor = cursorResult.cursor + const messages: Message[] = [] + try { + let hasMore = true + while (hasMore && messages.length < MAX_MESSAGES_PER_SUMMARY) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + throw new Error(batch.error || '读取消息失败') + } + hasMore = batch.hasMore === true + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) { + if (!hasMore) break + continue + } + const mapped = chatService.mapRowsToMessagesForApi(rows, sessionId) + for (const message of mapped) { + const createTime = Number(message.createTime || 0) + if (createTime < startTime || createTime > endTime) continue + messages.push(message) + if (messages.length >= MAX_MESSAGES_PER_SUMMARY) break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor).catch(() => {}) + } + + return messages.sort((a, b) => { + if (a.createTime !== b.createTime) return a.createTime - b.createTime + if (a.sortSeq !== b.sortSeq) return a.sortSeq - b.sortSeq + return a.localId - b.localId + }) + } + + private normalizeMessageText(message: Message): string { + const parsedContent = String(message.parsedContent || '').replace(/\s+/g, ' ').trim() + const quotedContent = String(message.quotedContent || '').replace(/\s+/g, ' ').trim() + const quotedSender = String(message.quotedSender || '').replace(/\s+/g, ' ').trim() + let text = parsedContent + if (quotedContent) { + const quote = quotedSender ? `${quotedSender}:${quotedContent}` : quotedContent + text = text && text !== '[引用消息]' ? `${text} [引用 ${quote}]` : `[引用 ${quote}]` + } + if (!text) { + text = String(message.linkTitle || message.fileName || message.appMsgDesc || '').replace(/\s+/g, ' ').trim() + } + if (!text) return '' + if (/^<\?xml|^ { + const readableMessages = messages.filter((message) => this.normalizeMessageText(message)) + const senderIds = Array.from(new Set( + readableMessages + .map((message) => String(message.senderUsername || '').trim()) + .filter(Boolean) + )) + const contacts = senderIds.length > 0 + ? (await chatService.enrichSessionsContactInfo(senderIds).catch(() => null))?.contacts || {} + : {} + const myWxid = String(this.config.getMyWxidCleaned() || '').trim() + + const lines = readableMessages.map((message) => { + const senderUsername = String(message.senderUsername || '').trim() + const senderName = message.isSend === 1 || (senderUsername && myWxid && senderUsername === myWxid) + ? '我' + : (contacts[senderUsername]?.displayName || senderUsername || '未知成员') + return `${formatTimestamp(message.createTime)} ${senderName}:${this.normalizeMessageText(message)}` + }) + + return { + transcript: lines.join('\n'), + readableMessages + } + } + + private async generateSummaryForPeriod(params: { + sessionId: string + displayName: string + avatarUrl?: string + periodStart: number + periodEnd: number + triggerType: GroupSummaryTriggerType + }): Promise { + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + try { + const messages = await this.readMessagesInPeriod(params.sessionId, params.periodStart, params.periodEnd) + const { transcript, readableMessages } = await this.buildTranscript(params.sessionId, messages) + if (readableMessages.length < MIN_SUMMARY_MESSAGES) { + return { + success: true, + skipped: true, + skippedReason: 'message_count_too_low', + message: `该时段可总结消息少于 ${MIN_SUMMARY_MESSAGES} 条,已跳过` + } + } + + const customPrompt = String(this.config.get('aiGroupSummarySystemPrompt') || '').trim() + const systemPrompt = customPrompt || DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT + const userPrompt = `群聊:${params.displayName} +总结时段:${formatTimestamp(params.periodStart)} 至 ${formatTimestamp(params.periodEnd)} +消息数量:${readableMessages.length} + +群聊记录: +${transcript} + +请只输出指定 JSON。` + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + + let rawOutput = '' + let responseFormatJson = true + let responseFormatFallback = false + let responseFormatFallbackReason = '' + const startedAt = Date.now() + try { + rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages, { responseFormatJson: true }) + } catch (error) { + if (!shouldFallbackJsonMode(error)) throw error + responseFormatJson = false + responseFormatFallback = true + responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' + rawOutput = await callChatCompletions(apiBaseUrl, apiKey, model, requestMessages) + } + + let topics: GroupSummaryTopic[] + let finalSummary: string + try { + topics = parseTopics(rawOutput) + finalSummary = buildSummaryText(topics) + } catch { + topics = [fallbackTopicFromRaw(rawOutput)] + finalSummary = buildSummaryText(topics) + } + + const log: GroupSummaryLog = { + endpoint, + model, + temperature: API_TEMPERATURE, + triggerType: params.triggerType, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + messageCount: messages.length, + readableMessageCount: readableMessages.length, + systemPrompt, + userPrompt, + rawOutput, + finalSummary, + durationMs: Date.now() - startedAt, + createdAt: Date.now(), + responseFormatJson, + responseFormatFallback, + responseFormatFallbackReason, + parsedTopics: topics + } + + const record = groupSummaryRecordService.addRecord({ + sessionId: params.sessionId, + displayName: params.displayName, + avatarUrl: params.avatarUrl, + triggerType: params.triggerType, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + messageCount: messages.length, + readableMessageCount: readableMessages.length, + topics, + summaryText: finalSummary, + rawOutput, + log + }) + + return { success: true, message: '群聊总结已生成', recordId: record.id, record } + } catch (error) { + return { success: false, message: `生成失败:${(error as Error).message || String(error)}` } + } + } + + private async generateSummariesForPeriods(params: { + sessionId: string + displayName: string + avatarUrl?: string + periods: Array<{ start: number; end: number }> + triggerType: GroupSummaryTriggerType + }): Promise { + const records: GroupSummaryRecordSummary[] = [] + let skipped = 0 + let failed = 0 + let firstError = '' + + for (const period of params.periods) { + const result = await this.generateSummaryForPeriod({ + sessionId: params.sessionId, + displayName: params.displayName, + avatarUrl: params.avatarUrl, + periodStart: period.start, + periodEnd: period.end, + triggerType: params.triggerType + }) + if (result.success && result.record) { + records.push(result.record) + continue + } + if (result.success && result.skipped) { + skipped += 1 + continue + } + failed += 1 + if (!firstError) firstError = result.message + } + + const generated = records.length + const parts = [`生成 ${generated} 段`, `跳过 ${skipped} 段`] + if (failed > 0) parts.push(`失败 ${failed} 段`) + const message = failed > 0 && generated === 0 && skipped === 0 + ? (firstError || '群聊总结生成失败') + : `群聊总结完成:${parts.join(',')}` + + return { + success: generated > 0 || skipped > 0 || failed === 0, + message, + generated, + skipped, + records + } + } +} + +export const groupSummaryService = new GroupSummaryService() diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 1e51ff0..52eece8 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1431,6 +1431,20 @@ class HttpService { return '.jpg' } + private writeFileIfLarger(fullPath: string, data: Buffer): void { + if (fs.existsSync(fullPath)) { + try { + const stat = fs.statSync(fullPath) + if (!stat.isFile()) return + if (data.length <= stat.size) return + } catch { + // If the existing export cannot be inspected, overwrite it below. + } + } + + fs.writeFileSync(fullPath, data) + } + private async exportMediaForMessages( messages: Message[], talker: string, @@ -1530,9 +1544,7 @@ class HttpService { const targetDir = path.join(sessionDir, 'images') const fullPath = path.join(targetDir, fileName) this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.writeFileSync(fullPath, imageBuffer) - } + this.writeFileIfLarger(fullPath, imageBuffer) const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` return { kind: 'image', fileName, fullPath, relativePath } } @@ -1545,9 +1557,7 @@ class HttpService { const targetDir = path.join(sessionDir, 'images') const fullPath = path.join(targetDir, fileName) this.ensureDir(targetDir) - if (!fs.existsSync(fullPath)) { - fs.copyFileSync(imagePath, fullPath) - } + this.writeFileIfLarger(fullPath, imageBuffer) const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}` return { kind: 'image', fileName, fullPath, relativePath } } diff --git a/electron/services/insightProfileService.ts b/electron/services/insightProfileService.ts new file mode 100644 index 0000000..79854a2 --- /dev/null +++ b/electron/services/insightProfileService.ts @@ -0,0 +1,1001 @@ +import fs from 'fs' +import path from 'path' +import https from 'https' +import http from 'http' +import { URL } from 'url' +import { app } from 'electron' +import { randomUUID, createHash } from 'crypto' +import { ConfigService } from './config' +import { chatService, type Message } from './chatService' +import { wcdbService } from './wcdbService' + +const API_TIMEOUT_MS = 45_000 +const API_TEMPERATURE = 0.7 +const MONTH_MATERIAL_CHAR_LIMIT = 45_000 +const DIRECT_MONTH_MESSAGE_LIMIT = 1000 +const MONTH_CURSOR_BATCH_SIZE = 800 +const MAX_RETRY_ATTEMPTS = 5 +const MONTHLY_OUTPUT_MIN_TOKENS = 1600 +const FINAL_OUTPUT_MIN_TOKENS = 2400 + +type ProfileStatusValue = 'none' | 'ready' | 'running' | 'failed' + +interface SharedAiModelConfig { + apiBaseUrl: string + apiKey: string + model: string + maxTokens: number +} + +interface ActiveProfileTask { + taskId: string + sessionId: string + displayName: string + controller: AbortController + phase: string + startedAt: number + cursor?: number +} + +interface MonthWindow { + key: string + label: string + startSec: number + endSec: number +} + +interface MonthStats { + total: number + mine: number + peer: number + activeDays: number + longestActiveDayStreak: number + longestSilenceDays: number + topHours: string[] + firstTime?: number + lastTime?: number +} + +interface PreparedMonthMaterial { + text: string + compressed: boolean + stats: MonthStats + scannedMessages: number + sampledMessages: number +} + +interface MonthSummary { + month: string + messageCount: number + compressed: boolean + sampledMessages: number + summary: string +} + +export interface InsightProfileRecord { + id: string + accountScope: string + sessionId: string + displayName: string + avatarUrl?: string + createdAt: number + updatedAt: number + rangeStart: number + rangeEnd: number + months: string[] + emptyMonths: string[] + monthlySummaries: MonthSummary[] + finalProfile: string + stats: { + scannedMessages: number + summarizedMonths: number + emptyMonths: number + compressedMonths: number + } + model: string +} + +export interface InsightProfileStatus { + sessionId: string + status: ProfileStatusValue + updatedAt?: number + error?: string + phase?: string + busy?: boolean +} + +export interface InsightProfileStatusListResult { + success: boolean + statuses: Record + activeTask?: { + sessionId: string + displayName: string + phase: string + startedAt: number + } + error?: string +} + +export interface InsightProfileGenerateResult { + success: boolean + message: string + cancelled?: boolean + profile?: InsightProfileRecord + error?: string +} + +class AbortRequestError extends Error { + constructor(message = '画像任务已取消') { + super(message) + this.name = 'AbortError' + } +} + +class ApiRequestError extends Error { + statusCode?: number + responseBody?: string + + constructor(message: string, statusCode?: number, responseBody?: string) { + super(message) + this.name = 'ApiRequestError' + this.statusCode = statusCode + this.responseBody = responseBody + } +} + +function isAbortError(error: unknown): boolean { + return (error as Error)?.name === 'AbortError' || String((error as Error)?.message || '').includes('取消') +} + +function abortIfNeeded(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new AbortRequestError() + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + abortIfNeeded(signal) + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + cleanup() + resolve() + }, ms) + const onAbort = () => { + if (settled) return + settled = true + clearTimeout(timer) + cleanup() + reject(new AbortRequestError()) + } + const cleanup = () => { + signal?.removeEventListener('abort', onAbort) + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +function normalizeApiMaxTokens(value: unknown): number { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 1024 + return Math.min(2_000_000, Math.max(1, Math.floor(numeric))) +} + +function buildApiUrl(baseUrl: string, apiPath: string): string { + const base = baseUrl.replace(/\/+$/, '') + const suffix = apiPath.startsWith('/') ? apiPath : `/${apiPath}` + return `${base}${suffix}` +} + +function formatPromptCurrentTime(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') + return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}` +} + +function appendPromptCurrentTime(prompt: string): string { + const base = String(prompt || '').trimEnd() + return base ? `${base}\n\n${formatPromptCurrentTime()}` : formatPromptCurrentTime() +} + +function clampText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\s+/g, ' ').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` +} + +function truncateStructuredText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\u0000/g, '').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 3))}...` +} + +function formatDateTime(timestampSeconds: number): string { + if (!Number.isFinite(timestampSeconds) || timestampSeconds <= 0) return '' + const date = new Date(timestampSeconds * 1000) + 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') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +function formatMonthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` +} + +function getMonthStart(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0) +} + +function toSeconds(date: Date): number { + return Math.floor(date.getTime() / 1000) +} + +function buildRecentTwelveMonthWindows(now: Date = new Date()): MonthWindow[] { + const currentMonthStart = getMonthStart(now) + const windows: MonthWindow[] = [] + for (let index = 11; index >= 0; index -= 1) { + const start = new Date(currentMonthStart) + start.setMonth(currentMonthStart.getMonth() - index) + const next = new Date(start) + next.setMonth(start.getMonth() + 1) + const isCurrentMonth = index === 0 + const end = isCurrentMonth ? now : new Date(next.getTime() - 1000) + const key = formatMonthKey(start) + windows.push({ + key, + label: key, + startSec: toSeconds(start), + endSec: Math.max(toSeconds(start), toSeconds(end)) + }) + } + return windows +} + +function callProfileApi( + config: SharedAiModelConfig, + messages: Array<{ role: string; content: string }>, + maxTokens: number, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + try { + abortIfNeeded(signal) + const endpoint = buildApiUrl(config.apiBaseUrl, '/chat/completions') + const urlObj = new URL(endpoint) + const payload = JSON.stringify({ + model: config.model, + messages, + max_tokens: normalizeApiMaxTokens(maxTokens), + temperature: API_TEMPERATURE, + stream: false + }) + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST' as const, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload).toString(), + Authorization: `Bearer ${config.apiKey}` + } + } + + const requestFn = urlObj.protocol === 'https:' ? https.request : http.request + const req = requestFn(requestOptions, (res) => { + let data = '' + res.on('data', (chunk) => { data += chunk }) + res.on('end', () => { + try { + if (res.statusCode && res.statusCode >= 400) { + reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) + return + } + const parsed = JSON.parse(data) + const content = parsed?.choices?.[0]?.message?.content + if (typeof content === 'string' && content.trim()) { + resolve(content.trim()) + } else { + reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`)) + } + } catch { + reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`)) + } + }) + }) + + const onAbort = () => { + req.destroy(new AbortRequestError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + + req.setTimeout(API_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('API 请求超时')) + }) + req.on('error', (error) => { + signal?.removeEventListener('abort', onAbort) + reject(isAbortError(error) || signal?.aborted ? new AbortRequestError() : error) + }) + req.on('close', () => { + signal?.removeEventListener('abort', onAbort) + }) + req.write(payload) + req.end() + } catch (error) { + reject(error) + } + }) +} + +async function callProfileApiWithRetry( + config: SharedAiModelConfig, + messages: Array<{ role: string; content: string }>, + maxTokens: number, + signal?: AbortSignal +): Promise { + let lastError: unknown + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) { + abortIfNeeded(signal) + try { + return await callProfileApi(config, messages, maxTokens, signal) + } catch (error) { + if (isAbortError(error) || signal?.aborted) throw new AbortRequestError() + lastError = error + if (attempt >= MAX_RETRY_ATTEMPTS) break + await sleep(Math.min(10_000, 800 * Math.pow(2, attempt - 1)), signal) + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError || 'API 请求失败')) +} + +class InsightProfileService { + private readonly config = ConfigService.getInstance() + private filePath: string | null = null + private loaded = false + private records: InsightProfileRecord[] = [] + private activeTask: ActiveProfileTask | null = null + private failedStatus = new Map() + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + const userDataPath = app?.getPath?.('userData') || process.cwd() + fs.mkdirSync(userDataPath, { recursive: true }) + this.filePath = path.join(userDataPath, 'weflow-insight-profiles.json') + return this.filePath + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + try { + const filePath = this.resolveFilePath() + if (!fs.existsSync(filePath)) return + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + const records = Array.isArray(parsed) ? parsed : parsed?.records + if (Array.isArray(records)) { + this.records = records.filter((item) => item && typeof item === 'object') as InsightProfileRecord[] + } + } catch { + this.records = [] + } + } + + private persist(): void { + try { + fs.writeFileSync(this.resolveFilePath(), JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8') + } catch { + // Profile generation should not crash when local persistence fails. + } + } + + private getCurrentAccountScope(): string { + const myWxid = String(this.config.getMyWxidCleaned() || '').trim() + if (myWxid) return `wxid:${myWxid}` + const dbPath = String(this.config.get('dbPath') || '').trim() + if (dbPath) { + const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16) + return `db:${hash}` + } + return 'default' + } + + private getSharedAiModelConfig(): SharedAiModelConfig { + const apiBaseUrl = String( + this.config.get('aiModelApiBaseUrl') + || this.config.get('aiInsightApiBaseUrl') + || '' + ).trim().replace(/\/+$/, '') + const apiKey = String( + this.config.get('aiModelApiKey') + || this.config.get('aiInsightApiKey') + || '' + ).trim() + const model = String( + this.config.get('aiModelApiModel') + || this.config.get('aiInsightApiModel') + || 'gpt-4o-mini' + ).trim() || 'gpt-4o-mini' + const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens')) + return { apiBaseUrl, apiKey, model, maxTokens } + } + + private findLatestRecord(sessionId: string): InsightProfileRecord | null { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return null + const matches = this.records + .filter((record) => record.accountScope === scope && record.sessionId === normalizedSessionId) + .sort((a, b) => b.updatedAt - a.updatedAt) + return matches[0] || null + } + + listProfileStatuses(sessionIds: string[]): InsightProfileStatusListResult { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + const latestBySession = new Map() + for (const record of this.records.filter((item) => item.accountScope === scope)) { + const existing = latestBySession.get(record.sessionId) + if (!existing || record.updatedAt > existing.updatedAt) { + latestBySession.set(record.sessionId, record) + } + } + + const statuses: Record = {} + for (const sessionId of normalizedIds) { + const activeForSession = this.activeTask?.sessionId === sessionId + if (activeForSession && this.activeTask) { + statuses[sessionId] = { + sessionId, + status: 'running', + phase: this.activeTask.phase, + updatedAt: this.activeTask.startedAt, + busy: false + } + continue + } + + const record = latestBySession.get(sessionId) + if (record) { + statuses[sessionId] = { + sessionId, + status: 'ready', + updatedAt: record.updatedAt, + busy: Boolean(this.activeTask) + } + continue + } + + const failed = this.failedStatus.get(sessionId) + if (failed) { + statuses[sessionId] = { + sessionId, + status: 'failed', + updatedAt: failed.updatedAt, + error: failed.error, + busy: Boolean(this.activeTask) + } + continue + } + + statuses[sessionId] = { + sessionId, + status: 'none', + busy: Boolean(this.activeTask) + } + } + + return { + success: true, + statuses, + activeTask: this.activeTask + ? { + sessionId: this.activeTask.sessionId, + displayName: this.activeTask.displayName, + phase: this.activeTask.phase, + startedAt: this.activeTask.startedAt + } + : undefined + } + } + + getProfileContextSection(sessionId: string): string { + const record = this.findLatestRecord(sessionId) + if (!record?.finalProfile) return '' + const rangeStart = formatDateTime(record.rangeStart) + const rangeEnd = formatDateTime(record.rangeEnd) + return [ + `联系人长期 AI 画像(覆盖 ${rangeStart} 至 ${rangeEnd},生成于 ${new Date(record.updatedAt).toLocaleString('zh-CN')}):`, + clampText(record.finalProfile, 3000) + ].join('\n') + } + + cancelProfile(sessionId?: string): { success: boolean; message: string } { + const normalizedSessionId = String(sessionId || '').trim() + if (!this.activeTask) return { success: true, message: '当前没有画像任务' } + if (normalizedSessionId && normalizedSessionId !== this.activeTask.sessionId) { + return { success: false, message: '当前运行中的画像任务不属于该联系人' } + } + this.activeTask.phase = '正在取消画像...' + this.activeTask.controller.abort() + return { success: true, message: '已请求取消画像任务' } + } + + cancelActiveTask(reason = '画像任务已取消'): void { + if (!this.activeTask) return + this.activeTask.phase = reason + this.activeTask.controller.abort() + } + + async generateProfile(params: { + sessionId: string + displayName?: string + avatarUrl?: string + }): Promise { + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId || sessionId.endsWith('@chatroom')) { + return { success: false, message: 'AI 画像仅支持私聊联系人' } + } + if (this.activeTask) { + return { + success: false, + message: `「${this.activeTask.displayName}」的画像正在生成,请等待完成或取消后再试` + } + } + + const aiConfig = this.getSharedAiModelConfig() + if (!aiConfig.apiBaseUrl || !aiConfig.apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + const existing = this.findLatestRecord(sessionId) + const displayName = clampText(params?.displayName || existing?.displayName || sessionId, 80) || sessionId + const controller = new AbortController() + const task: ActiveProfileTask = { + taskId: randomUUID(), + sessionId, + displayName, + controller, + phase: '正在初始化画像...', + startedAt: Date.now() + } + this.activeTask = task + + try { + const connectResult = await chatService.connect() + abortIfNeeded(controller.signal) + if (!connectResult.success) { + throw new Error('数据库连接失败,请先在“数据库连接”页完成配置') + } + + const windows = buildRecentTwelveMonthWindows() + const monthlySummaries: MonthSummary[] = [] + const emptyMonths: string[] = [] + let scannedMessages = 0 + let compressedMonths = 0 + + for (let index = 0; index < windows.length; index += 1) { + abortIfNeeded(controller.signal) + const month = windows[index] + task.phase = `正在读取 ${month.label} 聊天记录 (${index + 1}/12)...` + const messages = await this.readMonthMessages(sessionId, month, task) + scannedMessages += messages.length + + if (messages.length === 0) { + emptyMonths.push(month.label) + continue + } + + const material = this.prepareMonthMaterial(messages, displayName) + if (material.compressed) compressedMonths += 1 + + task.phase = `正在生成 ${month.label} 月度画像 (${monthlySummaries.length + 1})...` + const summary = await this.generateMonthlySummary(aiConfig, displayName, month.label, material, controller.signal) + monthlySummaries.push({ + month: month.label, + messageCount: material.scannedMessages, + compressed: material.compressed, + sampledMessages: material.sampledMessages, + summary + }) + } + + if (monthlySummaries.length === 0) { + throw new Error('最近 12 个自然月没有可用于画像的聊天记录') + } + + task.phase = '正在汇总完整 AI 画像...' + const finalProfile = await this.generateFinalProfile(aiConfig, displayName, windows, emptyMonths, monthlySummaries, controller.signal) + abortIfNeeded(controller.signal) + + const now = Date.now() + const record: InsightProfileRecord = { + id: randomUUID(), + accountScope: this.getCurrentAccountScope(), + sessionId, + displayName, + avatarUrl: String(params?.avatarUrl || existing?.avatarUrl || '').trim() || undefined, + createdAt: existing?.createdAt || now, + updatedAt: now, + rangeStart: windows[0].startSec, + rangeEnd: windows[windows.length - 1].endSec, + months: windows.map((month) => month.label), + emptyMonths, + monthlySummaries, + finalProfile, + stats: { + scannedMessages, + summarizedMonths: monthlySummaries.length, + emptyMonths: emptyMonths.length, + compressedMonths + }, + model: aiConfig.model + } + + this.upsertRecord(record) + this.failedStatus.delete(sessionId) + return { + success: true, + message: `已完成「${displayName}」的 AI 画像`, + profile: record + } + } catch (error) { + if (isAbortError(error) || controller.signal.aborted) { + return { success: false, cancelled: true, message: '画像已取消' } + } + const message = (error as Error).message || String(error) + if (!existing) { + this.failedStatus.set(sessionId, { error: message, updatedAt: Date.now() }) + } + return { success: false, message: `画像失败:${message}`, error: message } + } finally { + if (task.cursor) { + await wcdbService.closeMessageCursor(task.cursor).catch(() => {}) + } + if (this.activeTask?.taskId === task.taskId) { + this.activeTask = null + } + } + } + + private upsertRecord(record: InsightProfileRecord): void { + this.ensureLoaded() + this.records = this.records.filter((item) => !(item.accountScope === record.accountScope && item.sessionId === record.sessionId)) + this.records.push(record) + this.persist() + } + + private async readMonthMessages(sessionId: string, month: MonthWindow, task: ActiveProfileTask): Promise { + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + MONTH_CURSOR_BATCH_SIZE, + true, + month.startSec, + month.endSec + ) + if (!cursorResult.success || !cursorResult.cursor) { + throw new Error(cursorResult.error || `读取 ${month.label} 聊天记录失败`) + } + + task.cursor = cursorResult.cursor + const messages: Message[] = [] + try { + while (true) { + abortIfNeeded(task.controller.signal) + const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batch.success) { + throw new Error(batch.error || `读取 ${month.label} 聊天记录失败`) + } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length > 0) { + const mapped = chatService.mapRowsToMessagesLiteForApi(rows) + for (const message of mapped) { + const createTime = Number(message.createTime || 0) + if (createTime < month.startSec || createTime > month.endSec) continue + messages.push({ + ...message, + rawContent: clampText(message.rawContent || message.content || '', 1200), + content: undefined + }) + } + } + if (!batch.hasMore) break + } + messages.sort((a, b) => (a.createTime - b.createTime) || (a.sortSeq - b.sortSeq) || (a.localId - b.localId)) + return messages + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) + if (task.cursor === cursorResult.cursor) task.cursor = undefined + } + } + + private prepareMonthMaterial(messages: Message[], peerDisplayName: string): PreparedMonthMaterial { + const stats = this.computeMonthStats(messages) + const lines = messages.map((message) => this.formatMessageLine(message, peerDisplayName)) + const fullText = lines.join('\n') + if (messages.length <= DIRECT_MONTH_MESSAGE_LIMIT && fullText.length <= MONTH_MATERIAL_CHAR_LIMIT) { + return { + text: fullText, + compressed: false, + stats, + scannedMessages: messages.length, + sampledMessages: messages.length + } + } + + const statsText = this.formatMonthStats(stats) + const selectedIndices = this.selectRepresentativeIndices(messages) + const sampledLines = Array.from(selectedIndices) + .sort((a, b) => a - b) + .map((index) => lines[index]) + + const sampledText = this.fitLinesToBudget(sampledLines, Math.max(10_000, MONTH_MATERIAL_CHAR_LIMIT - statsText.length - 800)) + const text = [ + '本月聊天记录已完整扫描。由于原文过长,以下为本地统计摘要、时间均匀抽样与高信息密度片段;请基于这些证据谨慎概括,不要把抽样片段视为全部事实。', + '', + statsText, + '', + '代表性聊天片段(按时间顺序):', + sampledText || '无可读文本片段' + ].join('\n') + + return { + text: truncateStructuredText(text, MONTH_MATERIAL_CHAR_LIMIT), + compressed: true, + stats, + scannedMessages: messages.length, + sampledMessages: sampledLines.length + } + } + + private computeMonthStats(messages: Message[]): MonthStats { + const daySet = new Set() + const hourCounts = new Map() + let mine = 0 + let peer = 0 + let firstTime = 0 + let lastTime = 0 + + for (const message of messages) { + const ts = Math.max(0, Math.floor(Number(message.createTime || 0))) + if (ts > 0) { + if (!firstTime || ts < firstTime) firstTime = ts + if (!lastTime || ts > lastTime) lastTime = ts + const date = new Date(ts * 1000) + const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` + daySet.add(day) + const hour = date.getHours() + hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1) + } + if (message.isSend === 1) mine += 1 + else peer += 1 + } + + const sortedDays = Array.from(daySet).sort() + let longestActiveDayStreak = 0 + let currentStreak = 0 + let longestSilenceDays = 0 + let prevDayTime = 0 + for (const day of sortedDays) { + const dayTime = new Date(`${day}T00:00:00`).getTime() + if (!prevDayTime || dayTime - prevDayTime === 86_400_000) { + currentStreak += 1 + } else { + currentStreak = 1 + longestSilenceDays = Math.max(longestSilenceDays, Math.floor((dayTime - prevDayTime) / 86_400_000) - 1) + } + prevDayTime = dayTime + longestActiveDayStreak = Math.max(longestActiveDayStreak, currentStreak) + } + + const topHours = Array.from(hourCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([hour, count]) => `${String(hour).padStart(2, '0')}:00(${count}条)`) + + return { + total: messages.length, + mine, + peer, + activeDays: daySet.size, + longestActiveDayStreak, + longestSilenceDays, + topHours, + firstTime: firstTime || undefined, + lastTime: lastTime || undefined + } + } + + private formatMonthStats(stats: MonthStats): string { + return [ + '本月统计摘要:', + `消息总数:${stats.total}`, + `我发送:${stats.mine};对方发送:${stats.peer}`, + `活跃天数:${stats.activeDays}`, + `最长连续活跃:${stats.longestActiveDayStreak} 天`, + `最长无互动间隔:${stats.longestSilenceDays} 天`, + `主要互动时段:${stats.topHours.length > 0 ? stats.topHours.join('、') : '无'}`, + `首条消息时间:${stats.firstTime ? formatDateTime(stats.firstTime) : '无'}`, + `末条消息时间:${stats.lastTime ? formatDateTime(stats.lastTime) : '无'}` + ].join('\n') + } + + private selectRepresentativeIndices(messages: Message[]): Set { + const selected = new Set() + const addWindow = (center: number, radius = 2) => { + for (let index = Math.max(0, center - radius); index <= Math.min(messages.length - 1, center + radius); index += 1) { + selected.add(index) + } + } + + if (messages.length === 0) return selected + + const bucketCount = Math.min(24, Math.max(6, Math.ceil(messages.length / 250))) + for (let bucket = 0; bucket < bucketCount; bucket += 1) { + const start = Math.floor((messages.length * bucket) / bucketCount) + const end = Math.max(start, Math.floor((messages.length * (bucket + 1)) / bucketCount) - 1) + addWindow(start, 1) + addWindow(Math.floor((start + end) / 2), 1) + addWindow(end, 1) + } + + const scored = messages.map((message, index) => ({ + index, + score: this.scoreMessageForProfile(message) + })) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 120) + + for (const item of scored) { + addWindow(item.index, 2) + } + + addWindow(0, 3) + addWindow(Math.floor(messages.length / 2), 3) + addWindow(messages.length - 1, 3) + return selected + } + + private scoreMessageForProfile(message: Message): number { + const content = this.extractReadableContent(message) + if (!content || content.startsWith('[')) return 0 + const emotionWords = [ + '谢谢', '感谢', '抱歉', '对不起', '开心', '高兴', '难过', '委屈', '生气', '焦虑', '压力', '累', + '想你', '喜欢', '爱', '在乎', '担心', '害怕', '烦', '崩溃', '见面', '一起', '约', '陪', '帮' + ] + let score = Math.min(80, content.length) + if (/[??]/.test(content)) score += 18 + if (/[!!]{1,}/.test(content)) score += 8 + for (const word of emotionWords) { + if (content.includes(word)) score += 24 + } + if (message.quotedContent) score += 12 + if (content.length >= 80) score += 16 + return score + } + + private fitLinesToBudget(lines: string[], budget: number): string { + const output: string[] = [] + let used = 0 + for (const line of lines) { + const normalized = clampText(line, 700) + const nextUsed = used + normalized.length + 1 + if (nextUsed > budget) break + output.push(normalized) + used = nextUsed + } + return output.join('\n') + } + + private extractReadableContent(message: Message): string { + const parsed = String(message.parsedContent || '').trim() + if (parsed) return clampText(parsed, 600) + const raw = String(message.rawContent || message.content || '').trim() + if (!raw) return '[其他消息]' + if (/^(<\?xml| { + const systemPrompt = `你是一个克制、细致的长期关系画像分析助手。你只根据给定聊天材料分析,不做诊断,不给道德评判,不编造事实。你的目标是从一个自然月的聊天中提炼这个人的沟通风格、情绪模式、关系需求、关注主题、互动节奏,以及与“我”的关系变化线索。 + +要求: +1. 输出中文纯文本,不使用 Markdown。 +2. 控制在 400-600 字。 +3. 必须区分“有证据支持的观察”和“不确定但可留意的倾向”。 +4. 不要逐条复述聊天记录,要提炼稳定模式和本月变化。 +5. 对敏感内容使用概括,不输出隐私细节。 +6. 如果材料经过压缩或抽样,明确保持谨慎,不把局部片段当成全部事实。` + + const userPrompt = appendPromptCurrentTime(`对象:${displayName} +月份:${monthLabel} +材料状态:${material.compressed ? '本月原始记录过长,已完整扫描后进行本地结构化压缩与代表性抽样' : '本月记录未超过预算,按时间顺序提供'} +扫描消息数:${material.scannedMessages} +用于输入的代表消息数:${material.sampledMessages} + +本月聊天材料: +${material.text} + +请输出本月画像总结,覆盖:沟通风格、情绪与压力线索、关注主题、与我的互动模式、本月关系变化、后续相处建议。`) + + return callProfileApiWithRetry( + config, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + Math.max(config.maxTokens, MONTHLY_OUTPUT_MIN_TOKENS), + signal + ) + } + + private async generateFinalProfile( + config: SharedAiModelConfig, + displayName: string, + months: MonthWindow[], + emptyMonths: string[], + monthlySummaries: MonthSummary[], + signal?: AbortSignal + ): Promise { + const systemPrompt = `你是用户的私人关系画像整理助手。你需要把最近 12 个自然月的月度画像总结合成为一份长期 AI 画像。你只能基于月度总结和空月信息判断,不编造缺失月份内容。 + +要求: +1. 输出中文纯文本,不使用 Markdown。 +2. 控制在 900-1400 字。 +3. 画像要稳定、克制、可用于后续 AI 见解上下文。 +4. 优先总结长期模式,其次指出近三个月变化。 +5. 给出与这个人互动时最值得注意的 3-5 条原则。 +6. 不做医学、法律、心理诊断;避免贴标签式结论。` + + const summaryText = monthlySummaries + .map((item) => `【${item.month}】消息数:${item.messageCount};材料${item.compressed ? '已压缩抽样' : '未压缩'}\n${item.summary}`) + .join('\n\n') + + const userPrompt = appendPromptCurrentTime(`对象:${displayName} +时间范围:${months[0].label} 至 ${months[months.length - 1].label} +空月:${emptyMonths.length > 0 ? emptyMonths.join('、') : '无'} + +月度总结: +${summaryText} + +请生成完整 AI 画像,结构包含:整体印象、沟通风格、情绪/压力模式、核心关注、关系互动模式、最近变化、相处建议、后续 AI 见解使用注意事项。`) + + return callProfileApiWithRetry( + config, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + Math.max(config.maxTokens, FINAL_OUTPUT_MIN_TOKENS), + signal + ) + } +} + +export const insightProfileService = new InsightProfileService() diff --git a/electron/services/insightRecordService.ts b/electron/services/insightRecordService.ts index 762b372..b36b203 100644 --- a/electron/services/insightRecordService.ts +++ b/electron/services/insightRecordService.ts @@ -4,7 +4,24 @@ import path from 'path' import { createHash, randomUUID } from 'crypto' import { ConfigService } from './config' -export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' +export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test' | 'manual' | 'message_analysis' +export type InsightRecordSourceType = 'insight' | 'message_analysis' + +export interface MessageInsightAnalysis { + explicitText: string + emotion: string + intent: string + topic: string +} + +export interface MessageInsightTarget { + targetLocalId: number + targetCreateTime: number + targetMessageKey: string + targetSenderName: string + targetTextPreview: string + analysis: MessageInsightAnalysis +} export interface InsightRecordLog { endpoint: string @@ -20,11 +37,29 @@ export interface InsightRecordLog { finalInsight: string durationMs: number createdAt: number + responseFormatJson?: boolean + responseFormatFallback?: boolean + responseFormatFallbackReason?: string + targetMessage?: { + localId: number + createTime: number + messageKey: string + senderName: string + textPreview: string + } + contextStats?: { + requested: number + beforeTarget: number + afterTarget: number + readError?: string + } + parsedAnalysis?: MessageInsightAnalysis } export interface InsightRecord { id: string accountScope: string + sourceType: InsightRecordSourceType createdAt: number sessionId: string displayName: string @@ -32,11 +67,13 @@ export interface InsightRecord { triggerReason: InsightRecordTriggerReason insight: string read: boolean + messageInsight?: MessageInsightTarget log: InsightRecordLog } export interface InsightRecordSummary { id: string + sourceType: InsightRecordSourceType createdAt: number sessionId: string displayName: string @@ -44,6 +81,7 @@ export interface InsightRecordSummary { triggerReason: InsightRecordTriggerReason insight: string read: boolean + messageInsight?: MessageInsightTarget } export interface InsightRecordContactFacet { @@ -58,6 +96,7 @@ export interface InsightRecordFilters { sessionId?: string startTime?: number endTime?: number + sourceType?: InsightRecordSourceType | 'all' limit?: number offset?: number } @@ -136,13 +175,15 @@ class InsightRecordService { private toSummary(record: InsightRecord): InsightRecordSummary { return { id: record.id, + sourceType: record.sourceType || 'insight', createdAt: record.createdAt, sessionId: record.sessionId, displayName: record.displayName, avatarUrl: record.avatarUrl, triggerReason: record.triggerReason, insight: record.insight, - read: record.read + read: record.read, + messageInsight: record.messageInsight } } @@ -156,8 +197,10 @@ class InsightRecordService { sessionId: string displayName: string avatarUrl?: string + sourceType?: InsightRecordSourceType triggerReason: InsightRecordTriggerReason insight: string + messageInsight?: MessageInsightTarget log: InsightRecordLog }): InsightRecord { this.ensureLoaded() @@ -166,6 +209,7 @@ class InsightRecordService { const record: InsightRecord = { id: randomUUID(), accountScope: scope, + sourceType: input.sourceType || 'insight', createdAt: now, sessionId: input.sessionId, displayName: input.displayName, @@ -173,6 +217,7 @@ class InsightRecordService { triggerReason: input.triggerReason, insight: input.insight, read: false, + messageInsight: input.messageInsight, log: input.log } @@ -207,6 +252,7 @@ class InsightRecordService { const keyword = String(filters.keyword || '').trim().toLowerCase() const sessionId = String(filters.sessionId || '').trim() + const sourceType = String(filters.sourceType || 'all').trim() const startTime = Number(filters.startTime || 0) const endTime = Number(filters.endTime || 0) const offset = Math.max(0, Math.floor(Number(filters.offset || 0))) @@ -215,10 +261,22 @@ class InsightRecordService { const filtered = allScoped .filter((record) => { if (sessionId && record.sessionId !== sessionId) return false + const recordSourceType = record.sourceType || 'insight' + if (sourceType !== 'all' && sourceType && recordSourceType !== sourceType) 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() + const haystack = [ + record.displayName, + record.sessionId, + record.insight, + record.messageInsight?.targetSenderName, + record.messageInsight?.targetTextPreview, + record.messageInsight?.analysis?.explicitText, + record.messageInsight?.analysis?.emotion, + record.messageInsight?.analysis?.intent, + record.messageInsight?.analysis?.topic + ].join('\n').toLowerCase() if (!haystack.includes(keyword)) return false } return true @@ -256,6 +314,36 @@ class InsightRecordService { return { success: true, record } } + findLatestMessageAnalysis(input: { + sessionId: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + }): InsightRecord | null { + this.ensureLoaded() + const scope = this.getCurrentAccountScope() + const sessionId = String(input.sessionId || '').trim() + if (!sessionId) return null + const targetLocalId = Math.floor(Number(input.targetLocalId || 0)) + const targetCreateTime = Math.floor(Number(input.targetCreateTime || 0)) + const targetMessageKey = String(input.targetMessageKey || '').trim() + const matches = this.records + .filter((record) => { + if (record.accountScope !== scope) return false + if ((record.sourceType || 'insight') !== 'message_analysis') return false + if (record.sessionId !== sessionId) return false + const target = record.messageInsight + if (!target) return false + if (targetLocalId > 0 && Number(target.targetLocalId || 0) === targetLocalId) { + if (targetCreateTime <= 0 || Number(target.targetCreateTime || 0) === targetCreateTime) return true + } + if (targetMessageKey && target.targetMessageKey === targetMessageKey) return true + return false + }) + .sort((a, b) => b.createdAt - a.createdAt) + return matches[0] || null + } + markRecordRead(id: string): { success: boolean; error?: string } { this.ensureLoaded() const normalizedId = String(id || '').trim() diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 01c6f1e..c23b093 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -1,4 +1,4 @@ -/** +/** * insightService.ts * * AI 见解后台服务: @@ -21,7 +21,13 @@ import { chatService, ChatSession, Message } from './chatService' import { snsService } from './snsService' import { weiboService } from './social/weiboService' import { showNotification } from '../windows/notificationWindow' -import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService' +import { insightProfileService } from './insightProfileService' +import { + insightRecordService, + type InsightRecordLog, + type InsightRecordTriggerReason, + type MessageInsightAnalysis +} from './insightRecordService' // ─── 常量 ──────────────────────────────────────────────────────────────────── @@ -91,12 +97,34 @@ interface SharedAiModelConfig { maxTokens: number } +interface SessionInsightTriggerResult { + success: boolean + message: string + recordId?: string + insight?: string + skipped?: boolean + notificationEnabled?: boolean +} + type InsightFilterMode = 'whitelist' | 'blacklist' interface CallApiOptions { temperature?: number disableThinking?: boolean useMaxCompletionTokens?: boolean + responseFormatJson?: boolean +} + +class ApiRequestError extends Error { + statusCode?: number + responseBody?: string + + constructor(message: string, statusCode?: number, responseBody?: string) { + super(message) + this.name = 'ApiRequestError' + this.statusCode = statusCode + this.responseBody = responseBody + } } // ─── 日志 ───────────────────────────────────────────────────────────────────── @@ -227,6 +255,52 @@ function normalizeFootprintInsight(text: string): string { return normalized } +function clampText(value: unknown, maxLength: number): string { + const text = String(value || '').replace(/\s+/g, ' ').trim() + if (text.length <= maxLength) return text + return `${text.slice(0, Math.max(0, maxLength - 1))}…` +} + +function stripJsonFence(value: string): string { + const text = String(value || '').trim() + const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i) + if (fenced) return fenced[1].trim() + const firstBrace = text.indexOf('{') + const lastBrace = text.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace > firstBrace) { + return text.slice(firstBrace, lastBrace + 1).trim() + } + return text +} + +function parseMessageInsightAnalysis(rawOutput: string): MessageInsightAnalysis { + let parsed: unknown + try { + parsed = JSON.parse(stripJsonFence(rawOutput)) + } catch { + throw new Error('模型输出格式异常:不是合法 JSON') + } + if (!parsed || typeof parsed !== 'object') { + throw new Error('模型输出格式异常:JSON 根节点不是对象') + } + const source = parsed as Record + const explicitText = clampText(source.explicit_text ?? source.explicitText, 120) + const emotion = clampText(source.emotion, 16) + const intent = clampText(source.intent, 20) + const topic = clampText(source.topic, 20) + if (!explicitText || !emotion || !intent || !topic) { + throw new Error('模型输出格式异常:缺少必要字段') + } + return { explicitText, emotion, intent, topic } +} + +function shouldFallbackJsonMode(error: unknown): boolean { + const statusCode = Number((error as ApiRequestError)?.statusCode || 0) + if (statusCode === 400 || statusCode === 404 || statusCode === 422) return true + const text = `${(error as Error)?.message || ''}\n${(error as ApiRequestError)?.responseBody || ''}`.toLowerCase() + return text.includes('response_format') || text.includes('json_object') || text.includes('json mode') +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -251,23 +325,25 @@ function callApi( } const normalizedMaxTokens = normalizeApiMaxTokens(maxTokens) - const requestBody: Record = { + const payload: Record = { model, messages, temperature: options.temperature ?? API_TEMPERATURE, stream: false } if (options.useMaxCompletionTokens) { - requestBody.max_completion_tokens = normalizedMaxTokens + payload.max_completion_tokens = normalizedMaxTokens } else { - requestBody.max_tokens = normalizedMaxTokens + payload.max_tokens = normalizedMaxTokens } if (options.disableThinking) { - requestBody.thinking = { type: 'disabled' } - requestBody.enable_thinking = false + payload.thinking = { type: 'disabled' } + payload.enable_thinking = false } - - const body = JSON.stringify(requestBody) + if (options?.responseFormatJson) { + payload.response_format = { type: 'json_object' } + } + const body = JSON.stringify(payload) const requestOptions = { hostname: urlObj.hostname, @@ -288,6 +364,10 @@ function callApi( res.on('data', (chunk) => { data += chunk }) res.on('end', () => { try { + if (res.statusCode && res.statusCode >= 400) { + reject(new ApiRequestError(`API 请求失败 (${res.statusCode}): ${data.slice(0, 200)}`, res.statusCode, data)) + return + } const parsed = JSON.parse(data) const content = parsed?.choices?.[0]?.message?.content if (typeof content === 'string' && content.trim()) { @@ -388,6 +468,7 @@ class InsightService { this.clearTimers() this.clearRuntimeCache() this.processing = false + insightProfileService.cancelActiveTask('AI 见解服务已停止,画像任务已取消') if (hadActiveFlow) { insightLog('INFO', '已停止') } @@ -403,6 +484,7 @@ class InsightService { } if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { + insightProfileService.cancelActiveTask('数据库或账号配置已变化,画像任务已取消') this.clearRuntimeCache() } @@ -412,6 +494,7 @@ class InsightService { handleConfigCleared(): void { this.clearTimers() this.clearRuntimeCache() + insightProfileService.cancelActiveTask('配置已清除,画像任务已取消') this.processing = false } @@ -549,11 +632,14 @@ class InsightService { const sessionId = session.username?.trim() || '' const displayName = session.displayName || sessionId insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`) - await this.generateInsightForSession({ + const result = await this.generateInsightForSession({ sessionId, displayName, triggerReason: 'test' }) + if (!result.success) { + return { success: false, message: result.message } + } const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false return { success: true, @@ -566,6 +652,47 @@ class InsightService { } } + /** + * 手动对指定会话立即触发一次 AI 见解。 + * 只新增触发入口;实际上下文、朋友圈/微博拼接、prompt 和入库仍走 generateInsightForSession。 + */ + async triggerSessionInsight(params: { + sessionId: string + displayName?: string + avatarUrl?: string + }): Promise { + const sessionId = String(params?.sessionId || '').trim() + if (!sessionId) { + return { success: false, message: '当前会话无效,无法触发 AI 见解' } + } + if (!this.isEnabled()) { + return { success: false, message: '请先在设置中开启「AI 见解」' } + } + + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + try { + const connectResult = await chatService.connect() + if (!connectResult.success) { + return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' } + } + this.dbConnected = true + + const displayName = String(params?.displayName || sessionId).trim() || sessionId + insightLog('INFO', `手动触发当前会话见解:${displayName} (${sessionId})`) + return await this.generateInsightForSession({ + sessionId, + displayName, + triggerReason: 'manual' + }) + } catch (error) { + return { success: false, message: `触发失败:${(error as Error).message}` } + } + } + /** 获取今日触发统计(供设置页展示) */ getTodayStats(): { sessionId: string; count: number; times: string[] }[] { this.resetIfNewDay() @@ -691,6 +818,207 @@ ${topMentionText} } } + async generateMessageInsight(params: { + sessionId: string + displayName?: string + avatarUrl?: string + targetLocalId?: number + targetCreateTime?: number + targetMessageKey?: string + targetText: string + targetSenderName?: string + contextCount?: number + forceRefresh?: boolean + }): Promise<{ success: boolean; message: string; cached?: boolean; recordId?: string; data?: MessageInsightAnalysis }> { + const enabled = this.config.get('aiMessageInsightEnabled') === true + if (!enabled) { + return { success: false, message: '请先在设置中开启「消息解析」' } + } + + const sessionId = String(params?.sessionId || '').trim() + const targetText = clampText(params?.targetText || '', 500) + const targetCreateTime = Math.floor(Number(params?.targetCreateTime || 0)) + const targetLocalId = Math.floor(Number(params?.targetLocalId || 0)) + const targetMessageKey = String(params?.targetMessageKey || '').trim() + if (!sessionId || !targetText || targetCreateTime <= 0) { + return { success: false, message: '目标消息无效,无法解析' } + } + + if (params?.forceRefresh !== true) { + const cached = insightRecordService.findLatestMessageAnalysis({ + sessionId, + targetLocalId, + targetCreateTime, + targetMessageKey + }) + if (cached?.messageInsight?.analysis) { + return { + success: true, + message: '已读取缓存解析', + cached: true, + recordId: cached.id, + data: cached.messageInsight.analysis + } + } + } + + const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + const configuredContextCount = Number(this.config.get('aiMessageInsightContextCount') || 50) + const contextCount = Math.max(1, Math.min(200, Math.floor(Number(params?.contextCount || configuredContextCount) || 50))) + const displayName = await this.resolveInsightSessionDisplayName(sessionId, String(params?.displayName || sessionId)) + const targetSenderName = clampText(params?.targetSenderName || displayName, 40) || displayName + const targetTextPreview = clampText(targetText, 120) + let avatarUrl = String(params?.avatarUrl || '').trim() || undefined + if (!avatarUrl) { + try { + const contact = await chatService.getContactAvatar(sessionId) + avatarUrl = String(contact?.avatarUrl || '').trim() || undefined + } catch { + avatarUrl = undefined + } + } + + let beforeMessages: Message[] = [] + let afterMessages: Message[] = [] + let contextReadError = '' + try { + const aroundResult = await chatService.getMessagesAround( + sessionId, + { localId: targetLocalId, createTime: targetCreateTime, messageKey: targetMessageKey }, + contextCount + ) + if (aroundResult.success) { + beforeMessages = aroundResult.before || [] + afterMessages = aroundResult.after || [] + } else { + contextReadError = aroundResult.error || '读取上下文失败' + } + } catch (error) { + contextReadError = (error as Error).message || String(error) + } + + const formatLine = (message: Message) => { + const senderName = message.isSend === 1 ? '我' : (message.senderDisplayName || targetSenderName || displayName) + return `${this.formatInsightMessageTimestamp(message.createTime)} ${senderName}:${this.formatInsightMessageContent(message)}` + } + const beforeText = beforeMessages.length > 0 ? beforeMessages.map(formatLine).join('\n') : '无' + const afterText = afterMessages.length > 0 ? afterMessages.map(formatLine).join('\n') : '无' + + const DEFAULT_MESSAGE_INSIGHT_PROMPT = `你是一个克制、准确的聊天语义分析助手。你的任务是把用户选中的一句聊天消息做深度解析,帮助用户理解对方未明说的含义。 + +严格要求: +1. 必须且只能输出合法的纯 JSON。 +2. 禁止输出解释说明、前言后语,禁止使用 Markdown 或代码块。 +3. 不要编造上下文没有支持的信息;不确定时用谨慎表述。 +4. explicit_text 用自然中文说明这句话可能想表达的真实含义,80字以内。 +5. emotion、intent、topic 必须是短标签。 + +JSON 输出格式: +{ + "explicit_text": "暗示转明示,80字以内", + "emotion": "2-6字情绪标签", + "intent": "2-8字意图标签", + "topic": "2-8字话题标签" +}` + const customPrompt = String(this.config.get('aiMessageInsightSystemPrompt') || '').trim() + const systemPrompt = customPrompt || DEFAULT_MESSAGE_INSIGHT_PROMPT + const userPromptBase = `会话:${displayName} +目标发送者:${targetSenderName} +目标消息时间:${this.formatInsightMessageTimestamp(targetCreateTime)} + +目标消息: +${targetText} + +目标消息之前的上下文(${beforeMessages.length} 条): +${beforeText} + +目标消息之后的上下文(${afterMessages.length} 条): +${afterText} + +请分析目标消息,只输出指定 JSON。` + const userPrompt = appendPromptCurrentTime(userPromptBase) + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + + let rawOutput = '' + let responseFormatJson = true + let responseFormatFallback = false + let responseFormatFallbackReason = '' + const startedAt = Date.now() + try { + try { + rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens, { responseFormatJson: true }) + } catch (error) { + if (!shouldFallbackJsonMode(error)) throw error + responseFormatJson = false + responseFormatFallback = true + responseFormatFallbackReason = (error as Error).message || 'response_format 不受支持' + rawOutput = await callApi(apiBaseUrl, apiKey, model, requestMessages, API_TIMEOUT_MS, maxTokens) + } + const analysis = parseMessageInsightAnalysis(rawOutput) + const finalInsight = analysis.explicitText + const log: InsightRecordLog = { + endpoint, + model, + maxTokens, + temperature: API_TEMPERATURE, + triggerReason: 'message_analysis', + allowContext: true, + contextCount, + systemPrompt, + userPrompt, + rawOutput, + finalInsight, + durationMs: Date.now() - startedAt, + createdAt: Date.now(), + responseFormatJson, + responseFormatFallback, + responseFormatFallbackReason, + targetMessage: { + localId: targetLocalId, + createTime: targetCreateTime, + messageKey: targetMessageKey, + senderName: targetSenderName, + textPreview: targetTextPreview + }, + contextStats: { + requested: contextCount, + beforeTarget: beforeMessages.length, + afterTarget: afterMessages.length, + readError: contextReadError || undefined + }, + parsedAnalysis: analysis + } + const record = insightRecordService.addRecord({ + sessionId, + displayName, + avatarUrl, + sourceType: 'message_analysis', + triggerReason: 'message_analysis', + insight: finalInsight, + messageInsight: { + targetLocalId, + targetCreateTime, + targetMessageKey, + targetSenderName, + targetTextPreview, + analysis + }, + log + }) + return { success: true, message: '解析完成', cached: false, recordId: record.id, data: analysis } + } catch (error) { + return { success: false, message: `解析失败:${(error as Error).message}` } + } + } + // ── 私有方法 ──────────────────────────────────────────────────────────────── private isEnabled(): boolean { @@ -1200,10 +1528,10 @@ ${topMentionText} displayName: string triggerReason: InsightRecordTriggerReason silentDays?: number - }): Promise { + }): Promise { const { sessionId, displayName, triggerReason, silentDays } = params - if (!sessionId) return - if (!this.isEnabled()) return + if (!sessionId) return { success: false, message: '会话无效,无法生成见解' } + if (!this.isEnabled()) return { success: false, message: '请先在设置中开启「AI 见解」' } const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() const allowContext = this.config.get('aiInsightAllowContext') as boolean @@ -1221,7 +1549,7 @@ ${topMentionText} if (!apiBaseUrl || !apiKey) { insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成') - return + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } } // ── 构建 prompt ──────────────────────────────────────────────────────────── @@ -1242,6 +1570,7 @@ ${topMentionText} const momentsContextSection = await this.getMomentsContextSection(sessionId) const socialContextSection = await this.getSocialContextSection(sessionId) + const profileContextSection = insightProfileService.getProfileContextSection(sessionId) // ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)──── const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 @@ -1261,6 +1590,7 @@ ${topMentionText} ? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。` : '', contextSection, + profileContextSection, momentsContextSection, socialContextSection, '请给出你的见解(≤80字):' @@ -1311,9 +1641,9 @@ ${topMentionText} // 模型主动选择跳过 if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) { insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`) - return + return { success: true, message: `模型判断「${resolvedDisplayName}」暂无可生成的见解`, skipped: true } } - if (!this.isEnabled()) return + if (!this.isEnabled()) return { success: false, message: 'AI 见解已关闭,生成结果未保存' } const insight = result.trim() const notifTitle = `见解 · ${resolvedDisplayName}` @@ -1378,6 +1708,15 @@ ${topMentionText} insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`) this.recordTrigger(sessionId) + return { + success: true, + message: insightNotificationEnabled + ? `已生成「${resolvedDisplayName}」的 AI 见解,请查看通知弹窗` + : `已生成「${resolvedDisplayName}」的 AI 见解,AI 见解消息通知当前已关闭`, + recordId: record.id, + insight, + notificationEnabled: insightNotificationEnabled + } } catch (e) { insightDebugSection( 'ERROR', @@ -1385,6 +1724,7 @@ ${topMentionText} `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` ) insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`) + return { success: false, message: `生成失败:${(e as Error).message}` } } } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 37242a3..7f3de2d 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -10,6 +10,10 @@ const execFileAsync = promisify(execFile) type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } +type DbKeyPollResult = + | { status: 'success'; key: string; loginRequiredDetected: boolean } + | { status: 'process-ended'; loginRequiredDetected: boolean } + | { status: 'timeout'; loginRequiredDetected: boolean } export class KeyService { private readonly isMac = process.platform === 'darwin' @@ -58,6 +62,7 @@ export class KeyService { private readonly HKEY_CURRENT_USER = 0x80000001 private readonly ERROR_SUCCESS = 0 private readonly WM_CLOSE = 0x0010 + private readonly DB_KEY_PROCESS_CHECK_INTERVAL_MS = 1000 private getDllPath(): string { const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' @@ -342,30 +347,169 @@ export class KeyService { return null } - private async findPidByImageName(imageName: string): Promise { + private async findPidsByImageName(imageName: string): Promise { try { const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH']) const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + const pids: number[] = [] for (const line of lines) { if (line.startsWith('INFO:')) continue const parts = line.split('","').map((p) => p.replace(/^"|"$/g, '')) if (parts[0]?.toLowerCase() === imageName.toLowerCase()) { const pid = Number(parts[1]) - if (!Number.isNaN(pid)) return pid + if (!Number.isNaN(pid)) pids.push(pid) } } - return null + return pids } catch (e) { - return null + return [] } } - private async findWeChatPid(): Promise { - const names = ['Weixin.exe', 'WeChat.exe'] - for (const name of names) { - const pid = await this.findPidByImageName(name) + private async findWeChatPids(): Promise { + const pids: number[] = [] + const pushUnique = (pid: number | null | undefined) => { + if (!pid || pids.includes(pid)) return + pids.push(pid) + } + + for (const name of ['Weixin.exe', 'WeChat.exe']) { + const found = await this.findPidsByImageName(name) + found.forEach(pushUnique) + } + return pids + } + + private async isWeChatPidActive(pid: number): Promise { + const pids = await this.findWeChatPids() + if (pids.includes(pid)) return true + + const fallbackPid = await this.waitForWeChatWindow(250) + return fallbackPid === pid + } + + private async waitForWeChatPid(timeoutMs: number): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const pids = await this.findWeChatPids() + if (pids.length > 0) return pids[0] + + const fallbackPid = await this.waitForWeChatWindow(250) + if (fallbackPid) return fallbackPid + + await new Promise(r => setTimeout(r, 500)) + } + return null + } + + private getRemainingMs(deadline: number): number { + return Math.max(0, deadline - Date.now()) + } + + private async pollDbKeyFromHook( + pid: number, + deadline: number, + logs: string[], + onStatus?: (message: string, level: number) => void + ): Promise { + const keyBuffer = Buffer.alloc(128) + let loginRequiredDetected = false + let nextProcessCheckAt = 0 + + while (Date.now() < deadline) { + const now = Date.now() + if (now >= nextProcessCheckAt) { + nextProcessCheckAt = now + this.DB_KEY_PROCESS_CHECK_INTERVAL_MS + if (!await this.isWeChatPidActive(pid)) { + return { status: 'process-ended', loginRequiredDetected } + } + } + + if (this.pollKeyData(keyBuffer, keyBuffer.length)) { + const key = this.decodeUtf8(keyBuffer) + if (key.length === 64) { + onStatus?.('密钥获取成功', 1) + return { status: 'success', key, loginRequiredDetected } + } + } + + for (let i = 0; i < 5; i++) { + const statusBuffer = Buffer.alloc(256) + const levelOut = [0] + if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break + const msg = this.decodeUtf8(statusBuffer) + const level = levelOut[0] ?? 0 + if (msg) { + logs.push(msg) + if (this.isLoginRelatedText(msg)) { + loginRequiredDetected = true + } + onStatus?.(msg, level) + } + } + await new Promise((resolve) => setTimeout(resolve, 120)) + } + + return { status: 'timeout', loginRequiredDetected } + } + + private cleanupDbKeyHook(): void { + try { + this.cleanupHook() + } catch { } + } + + private buildInitHookError(): string { + const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' + if (error) { + if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { + return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' + } + return error + } + + const statusBuffer = Buffer.alloc(256) + const levelOut = [0] + const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) + ? this.decodeUtf8(statusBuffer) + : '' + return status || '初始化失败' + } + + private async waitForNextDbKeyPid(deadline: number, onStatus?: (message: string, level: number) => void): Promise { + while (this.getRemainingMs(deadline) > 0) { + onStatus?.('正在查找微信进程...', 0) + const pid = await this.waitForWeChatPid(Math.min(this.getRemainingMs(deadline), 30_000)) if (pid) return pid } + return null + } + + private shouldRetryAfterProcessLost(deadline: number): boolean { + return this.getRemainingMs(deadline) > 1000 + } + + private async delayBeforeRetry(): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + + private async waitForProcessRestart(deadline: number, onStatus?: (message: string, level: number) => void): Promise { + if (!this.shouldRetryAfterProcessLost(deadline)) return null + onStatus?.('检测到微信已退出,已清理 Hook,等待重新打开微信...', 0) + await this.delayBeforeRetry() + return this.waitForNextDbKeyPid(deadline, onStatus) + } + + private async detectLoginRequiredForLastPid(pid: number | null, loginRequiredDetected: boolean): Promise { + if (loginRequiredDetected) return true + if (!pid) return false + if (!await this.isWeChatPidActive(pid)) return false + return await this.detectWeChatLoginRequired(pid) + } + + private async findWeChatPid(): Promise { + const pids = await this.findWeChatPids() + if (pids.length > 0) return pids[0] const fallbackPid = await this.waitForWeChatWindow(5000) return fallbackPid ?? null } @@ -373,9 +517,8 @@ export class KeyService { private async waitForWeChatExit(timeoutMs = 8000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { - const weixinPid = await this.findPidByImageName('Weixin.exe') - const wechatPid = await this.findPidByImageName('WeChat.exe') - if (!weixinPid && !wechatPid) return true + const runningPids = await this.findWeChatPids() + if (runningPids.length === 0) return true await new Promise(r => setTimeout(r, 400)) } return false @@ -604,7 +747,7 @@ export class KeyService { return true } - // --- DB Key Logic (Unchanged core flow) --- + // --- DB Key Logic (core hook/poll flow unchanged) --- async autoGetDbKey( timeoutMs = 60_000, @@ -615,74 +758,56 @@ export class KeyService { if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' } const logs: string[] = [] - + const deadline = Date.now() + timeoutMs onStatus?.('正在查找微信进程...', 0) - const pid = await this.findWeChatPid() + let pid = await this.findWeChatPid() if (!pid) { const err = '未找到微信进程,请先启动微信' onStatus?.(err, 2) return { success: false, error: err } } + let lastAttemptLoginRequiredDetected = false - onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) - onStatus?.('正在检测微信界面组件...', 0) - await this.waitForWeChatWindowComponents(pid, 15000) + while (pid && this.getRemainingMs(deadline) > 0) { + onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) + onStatus?.('正在检测微信界面组件...', 0) + await this.waitForWeChatWindowComponents(pid, Math.min(15000, this.getRemainingMs(deadline))) - const ok = this.initHook(pid) - if (!ok) { - const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : '' - if (error) { - if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) { - const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行' - return { success: false, error: friendlyError } - } - return { success: false, error } + if (!await this.isWeChatPidActive(pid)) { + pid = await this.waitForProcessRestart(deadline, onStatus) + continue } - const statusBuffer = Buffer.alloc(256) - const levelOut = [0] - const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut) - ? this.decodeUtf8(statusBuffer) - : '' - return { success: false, error: status || '初始化失败' } - } - const keyBuffer = Buffer.alloc(128) - const start = Date.now() - let loginRequiredDetected = false - - try { - while (Date.now() - start < timeoutMs) { - if (this.pollKeyData(keyBuffer, keyBuffer.length)) { - const key = this.decodeUtf8(keyBuffer) - if (key.length === 64) { - onStatus?.('密钥获取成功', 1) - return { success: true, key, logs } - } + const ok = this.initHook(pid) + if (!ok) { + if (!await this.isWeChatPidActive(pid)) { + this.cleanupDbKeyHook() + pid = await this.waitForProcessRestart(deadline, onStatus) + continue } - - for (let i = 0; i < 5; i++) { - const statusBuffer = Buffer.alloc(256) - const levelOut = [0] - if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) break - const msg = this.decodeUtf8(statusBuffer) - const level = levelOut[0] ?? 0 - if (msg) { - logs.push(msg) - if (this.isLoginRelatedText(msg)) { - loginRequiredDetected = true - } - onStatus?.(msg, level) - } - } - await new Promise((resolve) => setTimeout(resolve, 120)) + return { success: false, error: this.buildInitHookError(), logs } } - } finally { + + let pollResult: DbKeyPollResult try { - this.cleanupHook() - } catch { } + pollResult = await this.pollDbKeyFromHook(pid, deadline, logs, onStatus) + } finally { + this.cleanupDbKeyHook() + } + + lastAttemptLoginRequiredDetected = pollResult.loginRequiredDetected + if (pollResult.status === 'success') { + return { success: true, key: pollResult.key, logs } + } + if (pollResult.status === 'process-ended') { + lastAttemptLoginRequiredDetected = false + pid = await this.waitForProcessRestart(deadline, onStatus) + continue + } + break } - const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid) + const loginRequired = await this.detectLoginRequiredForLastPid(pid, lastAttemptLoginRequiredDetected) if (loginRequired) { return { success: false, diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index b67a73b..6cc46a5 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -5,7 +5,7 @@ import { execFile, exec, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' import { createRequire } from 'module'; -const require = createRequire(import.meta.url); +const require = createRequire(__filename); const execFileAsync = promisify(execFile) const execAsync = promisify(exec) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 65d4941..5721f14 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -14,6 +14,7 @@ export interface SnsLivePhoto { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string } @@ -23,6 +24,7 @@ export interface SnsMedia { thumb: string md5?: string token?: string + thumbToken?: string key?: string encIdx?: string livePhoto?: SnsLivePhoto @@ -126,12 +128,22 @@ const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { let fixedUrl = url.replace('http://', 'https://') - // 只有非视频(即图片)才需要处理 /150 变 /0 + // 只有非视频(即图片)才需要处理路径末尾的尺寸标识(/150、/200等)变为 /0 if (!isVideo) { - fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1') + const [pathPart, queryPart] = fixedUrl.split('?') + const fixedPath = pathPart.replace(/\/(150|200|480)($|\?)/, '/0$2') + fixedUrl = queryPart ? `${fixedPath}?${queryPart}` : fixedPath } - if (!token || fixedUrl.includes('token=')) return fixedUrl + // 如果没有提供新token,直接返回 + if (!token) return fixedUrl + + // 移除已有的token和idx参数 + const [pathPart, queryPart] = fixedUrl.split('?') + if (queryPart) { + const params = queryPart.split('&').filter(p => !p.startsWith('token=') && !p.startsWith('idx=')) + fixedUrl = params.length > 0 ? `${pathPart}?${params.join('&')}` : pathPart + } // 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数 if (isVideo) { @@ -704,6 +716,7 @@ class SnsService { url: urlMatch ? urlMatch[1].trim() : '', thumb: thumbMatch ? thumbMatch[1].trim() : '', token: urlToken || thumbToken, + thumbToken: thumbToken, key: urlKey || thumbKey, md5: urlMd5, encIdx: urlEncIdx || thumbEncIdx @@ -716,19 +729,24 @@ class SnsService { const lpUrlTag = lx.match(/]*)>/i) const lpThumb = lx.match(/]*>([^<]+)<\/thumb>/i) const lpThumbTag = lx.match(/]*)>/i) - let lpToken: string | undefined, lpKey: string | undefined, lpEncIdx: string | undefined + let lpUrlToken: string | undefined, lpThumbToken: string | undefined + let lpKey: string | undefined, lpEncIdx: string | undefined if (lpUrlTag?.[1]) { const a = lpUrlTag[1] - lpToken = a.match(/token="([^"]+)"/i)?.[1] + lpUrlToken = a.match(/token="([^"]+)"/i)?.[1] lpKey = a.match(/key="([^"]+)"/i)?.[1] lpEncIdx = a.match(/enc_idx="([^"]+)"/i)?.[1] } - if (!lpToken && lpThumbTag?.[1]) lpToken = lpThumbTag[1].match(/token="([^"]+)"/i)?.[1] - if (!lpKey && lpThumbTag?.[1]) lpKey = lpThumbTag[1].match(/key="([^"]+)"/i)?.[1] + if (lpThumbTag?.[1]) { + const a = lpThumbTag[1] + lpThumbToken = a.match(/token="([^"]+)"/i)?.[1] + if (!lpKey) lpKey = a.match(/key="([^"]+)"/i)?.[1] + } item.livePhoto = { url: lpUrl ? lpUrl[1].trim() : '', thumb: lpThumb ? lpThumb[1].trim() : '', - token: lpToken, + token: lpUrlToken || lpThumbToken, + thumbToken: lpThumbToken, key: lpKey, encIdx: lpEncIdx } @@ -1181,16 +1199,18 @@ class SnsService { const fixedMedia = (post.media || []).map((m: any) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), + thumb: fixSnsUrl(m.thumb, m.thumbToken || m.token, false), md5: m.md5, token: m.token, + thumbToken: m.thumbToken, key: isVideoPost ? (videoKey || m.key) : m.key, encIdx: m.encIdx || m.enc_idx, livePhoto: m.livePhoto ? { ...m.livePhoto, url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.thumbToken || m.livePhoto.token, false), token: m.livePhoto.token, + thumbToken: m.livePhoto.thumbToken, key: videoKey || m.livePhoto.key || m.key, encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx } : undefined @@ -2060,6 +2080,8 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const zlib = require('zlib') const urlObj = new URL(url) + console.log(`[SnsService] 开始下载图片: url=${url.substring(0, 100)}..., key=${key || 'undefined'}`) + const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, @@ -2074,7 +2096,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const req = https.request(options, (res: any) => { + console.log(`[SnsService] CDN 响应: statusCode=${res.statusCode}, x-enc=${res.headers['x-enc']}, content-type=${res.headers['content-type']}`) if (res.statusCode !== 200 && res.statusCode !== 206) { + console.error(`[SnsService] CDN 请求失败: HTTP ${res.statusCode}`) resolve({ success: false, error: `HTTP ${res.statusCode}` }) return } @@ -2094,9 +2118,11 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class let decoded = raw const rawMagicMime = detectImageMime(raw, '') + console.log(`[SnsService] 原始数据: size=${raw.length}, mime=${rawMagicMime}, xEnc=${xEnc}`) // 图片逻辑 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 + console.log(`[SnsService] 解密判断: shouldDecrypt=${shouldDecrypt}, key=${key || 'undefined'}`) if (shouldDecrypt) { try { const keyStr = String(key).trim() @@ -2112,6 +2138,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const decryptedMagicMime = detectImageMime(decrypted, '') + console.log(`[SnsService] 解密后: mime=${decryptedMagicMime}`) if (decryptedMagicMime.startsWith('image/')) { decoded = decrypted } else if (!rawMagicMime.startsWith('image/')) { @@ -2124,7 +2151,9 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const decodedMagicMime = detectImageMime(decoded, '') + console.log(`[SnsService] 最终结果: mime=${decodedMagicMime}, isImage=${decodedMagicMime.startsWith('image/')}`) if (!decodedMagicMime.startsWith('image/')) { + console.error(`[SnsService] 图片解密失败: 原始mime=${rawMagicMime}, 解密后mime=${decodedMagicMime}, key=${key}`) resolve({ success: false, error: '图片解密失败:无法识别图片格式' }) return } diff --git a/shared/groupSummaryPrompt.json b/shared/groupSummaryPrompt.json new file mode 100644 index 0000000..9267994 --- /dev/null +++ b/shared/groupSummaryPrompt.json @@ -0,0 +1,3 @@ +{ + "defaultSystemPrompt": "你是一个群聊会议纪要式总结助手。你只根据用户提供的群聊记录总结,不编造记录中没有的信息。\n\n严格要求:\n1. 必须且只能输出合法纯 JSON,禁止 Markdown 和解释说明。\n2. 按话题分类总结,每个话题包含参与者、关键/矛盾点、结论。\n3. 参与者写群成员显示名;不确定参与者时写已有发言人。\n4. 关键/矛盾点必须来自聊天记录,避免泛泛而谈。\n5. 结论要短、具体;没有结论时写“暂无明确结论”。\n\nJSON 输出格式:\n{\n \"topics\": [\n {\n \"title\": \"话题名称\",\n \"participants\": [\"参与者A\", \"参与者B\"],\n \"key_points\": [\"关键点或矛盾点\"],\n \"conclusion\": \"结论\"\n }\n ]\n}" +} diff --git a/src/App.tsx b/src/App.tsx index f217457..78cdd84 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,6 +94,7 @@ function App() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [showCloseDialog, setShowCloseDialog] = useState(false) const [canMinimizeToTray, setCanMinimizeToTray] = useState(false) + const [closeRestoreMethod, setCloseRestoreMethod] = useState<'tray' | 'dock'>('tray') // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -120,6 +121,7 @@ function App() { useEffect(() => { const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => { setCanMinimizeToTray(Boolean(payload.canMinimizeToTray)) + setCloseRestoreMethod(payload.restoreMethod === 'dock' ? 'dock' : 'tray') setShowCloseDialog(true) }) @@ -685,6 +687,7 @@ function App() { handleWindowCloseAction(action, rememberChoice)} onCancel={() => handleWindowCloseAction('cancel')} /> diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index c858472..0bde562 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -551,8 +551,13 @@ export function ExportDateRangeDialog({ if (!open) return null return createPortal( -
-
event.stopPropagation()}> +
{ + event.stopPropagation() + onClose() + }} + >
event.stopPropagation()}>

{title}

+ ))} +
, + document.body + ) + : null + + const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement + ? createPortal( +
event.stopPropagation()} + > + {exportExcelColumnOptions.map((option) => ( + + ))} +
, + document.body + ) + : null + return (
@@ -273,6 +422,8 @@ export function ExportDefaultsSettingsForm({ - {showExportFileNamingModeSelect && ( -
- {exportFileNamingModeOptions.map((option) => ( - - ))} -
- )} + {fileNamingModeDropdown}
@@ -317,6 +448,8 @@ export function ExportDefaultsSettingsForm({ - {showExportExcelColumnsSelect && ( -
- {exportExcelColumnOptions.map((option) => ( - - ))} -
- )} + {excelColumnsDropdown}
@@ -371,7 +483,8 @@ export function ExportDefaultsSettingsForm({ notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true) }} /> - 图片 + - 最小化到系统托盘 - 继续保留后台进程和本地 API,稍后可从托盘恢复。 + {isDockRestore ? '隐藏主窗口' : '最小化到系统托盘'} + + {isDockRestore + ? '继续保留后台进程和本地 API,稍后可从 Dock 或重新打开应用恢复。' + : '继续保留后台进程和本地 API,稍后可从托盘恢复。'} + )} diff --git a/src/pages/Chat/ChatHeader.tsx b/src/pages/Chat/ChatHeader.tsx index 1293b7f..bb6e1e3 100644 --- a/src/pages/Chat/ChatHeader.tsx +++ b/src/pages/Chat/ChatHeader.tsx @@ -8,8 +8,10 @@ import { Info, Loader2, Mic, + Newspaper, RefreshCw, Search, + Sparkles, Users } from 'lucide-react' import { Avatar } from '../../components/Avatar' @@ -21,9 +23,11 @@ export interface ChatHeaderProps { isGroupChat: boolean standaloneSessionWindow: boolean showGroupMembersPanel: boolean + showGroupSummaryPanel: boolean showJumpPopover: boolean showInSessionSearch: boolean showDetailPanel: boolean + aiGroupSummaryEnabled: boolean shouldHideStandaloneDetailButton: boolean isPrivateSnsSupported: boolean isExportActionBusy: boolean @@ -32,10 +36,13 @@ export interface ChatHeaderProps { isBatchTranscribing: boolean runningBatchVoiceTaskType?: BatchVoiceTaskType isBatchDecrypting: boolean + isTriggeringSessionInsight: boolean isRefreshingMessages: boolean isLoadingMessages: boolean currentSessionId?: string | null jumpCalendarWrapRef: React.RefObject + onTriggerSessionInsight: () => void + onToggleGroupSummaryPanel: () => void onGroupAnalytics: () => void onToggleGroupMembersPanel: () => void onExportCurrentSession: () => void @@ -53,9 +60,11 @@ function ChatHeader({ isGroupChat, standaloneSessionWindow, showGroupMembersPanel, + showGroupSummaryPanel, showJumpPopover, showInSessionSearch, showDetailPanel, + aiGroupSummaryEnabled, shouldHideStandaloneDetailButton, isPrivateSnsSupported, isExportActionBusy, @@ -64,10 +73,13 @@ function ChatHeader({ isBatchTranscribing, runningBatchVoiceTaskType, isBatchDecrypting, + isTriggeringSessionInsight, isRefreshingMessages, isLoadingMessages, currentSessionId, jumpCalendarWrapRef, + onTriggerSessionInsight, + onToggleGroupSummaryPanel, onGroupAnalytics, onToggleGroupMembersPanel, onExportCurrentSession, @@ -102,6 +114,26 @@ function ChatHeader({ {isGroupChat &&
群聊
}
+ + {isGroupChat && aiGroupSummaryEnabled && ( + + )} {!standaloneSessionWindow && isGroupChat && (
{isGroupChat && !isSent && ( -
- {resolvedSenderName || '群成员'} +
+
+ {resolvedSenderName || '群成员'} +
+ {actionNode}
)} {children}
+ {!isGroupChat && !isSent && actionNode ? ( +
+ {actionNode} +
+ ) : null}
{isSelectionMode && isSent && } @@ -131,6 +141,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) { prev.isSelected === next.isSelected && prev.onContextMenu === next.onContextMenu && prev.onToggleSelection === next.onToggleSelection && + prev.actionNode === next.actionNode && prev.children === next.children && prev.portal === next.portal ) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 11651ef..a484486 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1747,6 +1747,29 @@ } } +.session-insight-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + + &.success { + color: var(--text-secondary); + } + + &.error { + color: var(--danger); + } + + .spin { + animation: spin 1s linear infinite; + } +} + .message-list { flex: 1; overflow-y: auto; @@ -1922,6 +1945,10 @@ .message-wrapper.new-message { animation: messagePop 0.35s ease-out; + + .message-bubble:not(.system) .bubble-content { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent); + } } @keyframes messagePop { @@ -3542,6 +3569,271 @@ } } +.group-summary-panel { + width: clamp(320px, 30vw, 420px); + min-width: 320px; + max-width: 420px; + + .group-summary-controls { + padding: 12px; + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 10px; + background: var(--bg-primary); + } + + .group-summary-date-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 8px; + + label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + } + + input, + .group-summary-date-trigger { + height: 32px; + min-width: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-primary); + padding: 0 9px; + font-size: 12px; + } + } + + .group-summary-date-picker { + position: relative; + min-width: 0; + } + + .group-summary-date-trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.16s ease; + + svg { + color: var(--text-secondary); + flex-shrink: 0; + } + + &:hover, + &.open { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: var(--bg-hover); + } + } + + .group-summary-calendar-popover { + right: auto; + left: 0; + top: calc(100% + 8px); + width: min(312px, calc(100vw - 32px)); + border-radius: 10px; + } + + .group-summary-icon-btn, + .group-summary-code-btn { + width: 30px; + height: 30px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--primary); + background: var(--bg-hover); + border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color)); + } + + .spin { + animation: spin 1s linear infinite; + } + } + + .group-summary-range-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + + button { + height: 30px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 8px; + background: var(--card-bg); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.16s ease; + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--card-bg)); + font-weight: 600; + } + } + } + + .group-summary-generate-btn { + height: 34px; + border: none; + border-radius: 8px; + background: var(--primary); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform 0.16s ease, opacity 0.16s ease; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + transform: translateY(-1px); + } + + .spin { + animation: spin 1s linear infinite; + } + } + + .group-summary-rule-hint, + .group-summary-count { + font-size: 12px; + color: var(--text-tertiary); + } + + .group-summary-list { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .group-summary-record { + border: 1px solid color-mix(in srgb, var(--border-color) 74%, transparent); + background: var(--bg-secondary); + border-radius: 10px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + } + + .group-summary-record-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + .group-summary-period { + display: block; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + } + + .group-summary-meta { + display: block; + margin-top: 3px; + color: var(--text-tertiary); + font-size: 12px; + } + + .group-summary-topic-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .group-summary-topic { + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + background: color-mix(in srgb, var(--card-bg) 88%, transparent); + padding: 9px; + + h5 { + margin: 0 0 7px; + font-size: 13px; + color: var(--text-primary); + line-height: 1.35; + } + } + + .group-summary-topic-row { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; + padding: 5px 0; + border-top: 1px dashed color-mix(in srgb, var(--border-color) 72%, transparent); + + span { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + word-break: break-word; + } + } +} + +.group-summary-log-modal { + width: min(860px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 32px)); + + .detail-content { + max-height: calc(100vh - 120px); + } +} + +.group-summary-log-pre { + margin: 0; + max-height: 260px; + overflow: auto; + padding: 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--card-bg) 86%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + @keyframes detailCardEnter { from { opacity: 0; @@ -5828,6 +6120,245 @@ margin-bottom: 5px; } +.sender-line { + display: flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-height: 18px; + margin-bottom: 5px; + + .sender-name { + min-width: 0; + margin-bottom: 0; + } +} + +.message-action-inline { + width: 24px; + height: 24px; + margin: 6px 0 0 4px; + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; +} + +.message-insight-trigger { + height: 18px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 5px; + font-size: 12px; + line-height: 18px; + opacity: 0; + transform: translateX(3px); + cursor: pointer; + transition: opacity 0.16s ease, color 0.16s ease, background 0.16s ease; + -webkit-app-region: no-drag; + + svg { + flex-shrink: 0; + } + + &:hover { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, transparent); + } + + &.compact { + width: 24px; + height: 24px; + padding: 0; + justify-content: center; + border-radius: 50%; + color: var(--primary); + background: transparent; + transform: none; + + svg { + width: 14px; + height: 14px; + } + } +} + +.message-wrapper-with-selection:hover .message-insight-trigger, +.message-insight-trigger:focus-visible { + opacity: 0.78; +} + +.message-wrapper-with-selection:hover .message-insight-trigger.compact, +.message-insight-trigger.compact:focus-visible { + opacity: 0.72; +} + +.message-insight-trigger.compact:hover { + opacity: 1; + background: color-mix(in srgb, var(--primary) 10%, transparent); +} + +.message-insight-trigger:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent); + outline-offset: 2px; +} + +.message-insight-backdrop { + position: fixed; + inset: 0; + z-index: 4100; + border: 0; + background: transparent; + cursor: default; +} + +.message-insight-card { + position: fixed; + z-index: 4101; + width: min(336px, calc(100vw - 16px)); + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + box-shadow: 0 16px 42px rgba(0, 0, 0, 0.18); + overflow: hidden; + animation: messageInsightPop 0.14s ease-out; + -webkit-app-region: no-drag; +} + +.message-insight-card-header { + height: 38px; + padding: 0 10px 0 12px; + display: flex; + align-items: center; + gap: 7px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + font-size: 13px; + font-weight: 700; + + svg { + color: var(--primary); + } +} + +.message-insight-refresh { + margin-left: auto; + width: 26px; + height: 26px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover:not(:disabled) { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, transparent); + } + + &:disabled { + cursor: default; + opacity: 0.62; + } +} + +.message-insight-card-body { + min-height: 132px; + padding: 13px 14px 14px; +} + +.message-insight-loading, +.message-insight-error { + min-height: 104px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; +} + +.message-insight-error { + flex-direction: column; + text-align: center; + + button { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--primary); + padding: 5px 10px; + cursor: pointer; + } +} + +.message-insight-text { + margin: 0; + color: var(--text-primary); + font-size: 14px; + line-height: 1.62; + white-space: pre-wrap; + word-break: break-word; +} + +.message-insight-divider { + height: 1px; + margin: 12px 0; + background: var(--border-color); +} + +.message-insight-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.message-insight-tag { + max-width: 100%; + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 7px; + font-size: 12px; + line-height: 1.3; + word-break: break-word; + + &.mood { + color: #8a5a00; + background: rgba(245, 158, 11, 0.13); + } + + &.intent { + color: #225f5c; + background: rgba(91, 147, 144, 0.14); + } +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes messageInsightPop { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + // Ambient Reply dark mode / alternate adjustments handled via CSS variables .link-message, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index c2c341a..e4514ab 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper, Star, Sparkles, Code2 } from 'lucide-react' import { useNavigate, useLocation } from 'react-router-dom' import { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' @@ -8,6 +8,7 @@ import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' import type { ChatRecordItem, ChatSession, Message } from '../types/models' +import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' @@ -68,6 +69,7 @@ interface QuotedMessageJumpTarget { type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' type GlobalMsgSearchResult = Message & { sessionId: string } +type GroupSummaryRangeMode = 1 | 2 | 4 | 8 | 12 | 24 interface GlobalMsgPrefixCacheEntry { keyword: string @@ -762,6 +764,17 @@ function formatYmdHmDateTime(timestamp?: number): string { return `${y}-${m}-${day} ${h}:${min}` } +function formatDateInputLocal(date: Date): string { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +function formatSummaryPeriod(start: number, end: number): string { + return `${formatYmdHmDateTime(start * 1000)} - ${formatYmdHmDateTime(end * 1000)}` +} + interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null @@ -1476,6 +1489,7 @@ function ChatPage(props: ChatPageProps) { const sessionListRef = useRef(null) const jumpCalendarWrapRef = useRef(null) const jumpPopoverPortalRef = useRef(null) + const groupSummaryDateWrapRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0) @@ -1537,6 +1551,18 @@ function ChatPage(props: ChatPageProps) { const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) const [showMessageInfo, setShowMessageInfo] = useState(null) const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) + const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false) + const [showGroupSummaryPanel, setShowGroupSummaryPanel] = useState(false) + const [groupSummaryRecords, setGroupSummaryRecords] = useState([]) + const [groupSummaryTotal, setGroupSummaryTotal] = useState(0) + const [groupSummaryLoading, setGroupSummaryLoading] = useState(false) + const [groupSummaryError, setGroupSummaryError] = useState(null) + const [groupSummaryDateFilter, setGroupSummaryDateFilter] = useState(() => formatDateInputLocal(new Date())) + const [groupSummaryRangeMode, setGroupSummaryRangeMode] = useState(4) + const [showGroupSummaryDatePopover, setShowGroupSummaryDatePopover] = useState(false) + const [isTriggeringGroupSummary, setIsTriggeringGroupSummary] = useState(false) + const [groupSummaryHint, setGroupSummaryHint] = useState<{ success: boolean; message: string } | null>(null) + const [groupSummaryLogRecord, setGroupSummaryLogRecord] = useState(null) // 多选模式 const [isSelectionMode, setIsSelectionMode] = useState(false) @@ -1637,6 +1663,11 @@ function ChatPage(props: ChatPageProps) { const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) + const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false) + const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50) + const [isTriggeringSessionInsight, setIsTriggeringSessionInsight] = useState(false) + const [sessionInsightHint, setSessionInsightHint] = useState<{ success: boolean; message: string } | null>(null) + const sessionInsightHintTimerRef = useRef(null) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) const isMessageListAtBottomRef = useRef(true) @@ -1856,6 +1887,130 @@ function ChatPage(props: ChatPageProps) { }) }, []) + const getGroupSummaryDateRangeSeconds = useCallback((dateValue = groupSummaryDateFilter) => { + const date = dateValue || formatDateInputLocal(new Date()) + const start = new Date(`${date}T00:00:00`) + if (!Number.isFinite(start.getTime())) { + const fallback = new Date() + fallback.setHours(0, 0, 0, 0) + const fallbackEnd = new Date(fallback) + fallbackEnd.setHours(23, 59, 59, 999) + return { startTime: Math.floor(fallback.getTime() / 1000), endTime: Math.floor(fallbackEnd.getTime() / 1000) } + } + const end = new Date(start) + end.setHours(23, 59, 59, 999) + return { startTime: Math.floor(start.getTime() / 1000), endTime: Math.floor(end.getTime() / 1000) } + }, [groupSummaryDateFilter]) + + const isGroupSummaryToday = useMemo(() => { + return (groupSummaryDateFilter || formatDateInputLocal(new Date())) === formatDateInputLocal(new Date()) + }, [groupSummaryDateFilter]) + + const loadGroupSummaryRecords = useCallback(async (sessionId?: string) => { + const targetSessionId = String(sessionId || currentSessionRef.current || '').trim() + if (!targetSessionId || !targetSessionId.endsWith('@chatroom')) return + const { startTime, endTime } = getGroupSummaryDateRangeSeconds() + setGroupSummaryLoading(true) + setGroupSummaryError(null) + try { + const result = await window.electronAPI.groupSummary.listRecords({ + sessionId: targetSessionId, + startTime, + endTime, + limit: 100 + }) + if (currentSessionRef.current !== targetSessionId) return + if (!result.success) { + setGroupSummaryRecords([]) + setGroupSummaryTotal(0) + setGroupSummaryError(result.error || '读取群聊总结失败') + return + } + setGroupSummaryRecords(result.records || []) + setGroupSummaryTotal(result.total || 0) + } catch (error) { + if (currentSessionRef.current !== targetSessionId) return + setGroupSummaryRecords([]) + setGroupSummaryTotal(0) + setGroupSummaryError((error as Error).message || String(error)) + } finally { + if (currentSessionRef.current === targetSessionId) { + setGroupSummaryLoading(false) + } + } + }, [getGroupSummaryDateRangeSeconds]) + + const resolveTodayGroupSummaryManualRange = useCallback(() => { + const nowSeconds = Math.floor(Date.now() / 1000) + const hours = Number(groupSummaryRangeMode) + return { startTime: nowSeconds - hours * 60 * 60, endTime: nowSeconds } + }, [groupSummaryRangeMode]) + + const triggerManualGroupSummary = useCallback(async () => { + const sessionId = String(currentSessionId || '').trim() + if (!sessionId || !sessionId.endsWith('@chatroom')) return + const sessionInfo = sessionMapRef.current.get(sessionId) + const selectedDate = groupSummaryDateFilter || formatDateInputLocal(new Date()) + const today = formatDateInputLocal(new Date()) + + setIsTriggeringGroupSummary(true) + setGroupSummaryHint({ success: true, message: '正在生成群聊总结...' }) + try { + if (selectedDate === today) { + const { startTime, endTime } = resolveTodayGroupSummaryManualRange() + if (startTime <= 0 || endTime <= startTime) { + setGroupSummaryHint({ success: false, message: '请选择有效的总结时段' }) + return + } + const result = await window.electronAPI.groupSummary.triggerManual({ + sessionId, + displayName: sessionInfo?.displayName || sessionId, + avatarUrl: sessionInfo?.avatarUrl, + startTime, + endTime + }) + if (result.success) { + setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' }) + if (!result.skipped) { + await loadGroupSummaryRecords(sessionId) + } + } else { + setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' }) + } + } else { + const result = await window.electronAPI.groupSummary.triggerDay({ + sessionId, + displayName: sessionInfo?.displayName || sessionId, + avatarUrl: sessionInfo?.avatarUrl, + date: selectedDate + }) + if (result.success) { + setGroupSummaryHint({ success: true, message: result.message || '群聊总结已生成' }) + await loadGroupSummaryRecords(sessionId) + } else { + setGroupSummaryHint({ success: false, message: result.message || '群聊总结生成失败' }) + } + } + } catch (error) { + setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) }) + } finally { + setIsTriggeringGroupSummary(false) + } + }, [currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, resolveTodayGroupSummaryManualRange]) + + const openGroupSummaryLog = useCallback(async (recordId: string) => { + try { + const result = await window.electronAPI.groupSummary.getRecord(recordId) + if (!result.success || !result.record) { + setGroupSummaryHint({ success: false, message: result.error || '读取总结日志失败' }) + return + } + setGroupSummaryLogRecord(result.record) + } catch (error) { + setGroupSummaryHint({ success: false, message: (error as Error).message || String(error) }) + } + }, []) + const handleToggleJumpPopover = useCallback(() => { if (!currentSessionId) return if (showJumpPopover) { @@ -2898,9 +3053,21 @@ function ChatPage(props: ChatPageProps) { return } setShowDetailPanel(false) + setShowGroupSummaryPanel(false) setShowGroupMembersPanel(true) }, [currentSessionId, showGroupMembersPanel, isGroupChatSession]) + const toggleGroupSummaryPanel = useCallback(() => { + if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) return + if (showGroupSummaryPanel) { + setShowGroupSummaryPanel(false) + return + } + setShowDetailPanel(false) + setShowGroupMembersPanel(false) + setShowGroupSummaryPanel(true) + }, [aiGroupSummaryEnabled, currentSessionId, showGroupSummaryPanel, isGroupChatSession]) + // 切换详情面板 const toggleDetailPanel = useCallback(() => { if (showDetailPanel) { @@ -2908,6 +3075,7 @@ function ChatPage(props: ChatPageProps) { return } setShowGroupMembersPanel(false) + setShowGroupSummaryPanel(false) setShowDetailPanel(true) if (currentSessionId) { void loadSessionDetail(currentSessionId) @@ -2924,6 +3092,15 @@ function ChatPage(props: ChatPageProps) { void loadGroupMembersPanel(currentSessionId) }, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) + useEffect(() => { + if (!showGroupSummaryPanel) return + if (!currentSessionId || !isGroupChatSession(currentSessionId) || !aiGroupSummaryEnabled) { + setShowGroupSummaryPanel(false) + return + } + void loadGroupSummaryRecords(currentSessionId) + }, [aiGroupSummaryEnabled, currentSessionId, groupSummaryDateFilter, loadGroupSummaryRecords, showGroupSummaryPanel, isGroupChatSession]) + useEffect(() => { const chatroomId = String(sessionDetail?.wxid || '').trim() if (!chatroomId || !chatroomId.includes('@chatroom')) return @@ -3005,6 +3182,10 @@ function ChatPage(props: ChatPageProps) { setIsLoadingRelationStats(false) setShowDetailPanel(false) setShowGroupMembersPanel(false) + setShowGroupSummaryPanel(false) + setGroupSummaryRecords([]) + setGroupSummaryError(null) + setGroupSummaryHint(null) setGroupPanelMembers([]) setGroupMembersError(null) setGroupMembersLoadingHint('') @@ -3095,6 +3276,36 @@ function ChatPage(props: ChatPageProps) { } }, []) + useEffect(() => { + let canceled = false + + const loadMessageInsightConfig = () => { + void Promise.all([ + configService.getAiMessageInsightEnabled(), + configService.getAiMessageInsightContextCount() + ]) + .then(([enabled, contextCount]) => { + if (canceled) return + setAiMessageInsightEnabled(enabled) + setAiMessageInsightContextCount(contextCount) + }) + .catch((error) => { + console.warn('加载消息解析配置失败:', error) + if (canceled) return + setAiMessageInsightEnabled(false) + setAiMessageInsightContextCount(50) + }) + } + + loadMessageInsightConfig() + const handleFocus = () => loadMessageInsightConfig() + window.addEventListener('focus', handleFocus) + return () => { + canceled = true + window.removeEventListener('focus', handleFocus) + } + }, []) + useEffect(() => { let cancelled = false void (async () => { @@ -3111,6 +3322,13 @@ function ChatPage(props: ChatPageProps) { // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId + messageInsightMemoryCache.clear() + setSessionInsightHint(null) + setIsTriggeringSessionInsight(false) + if (sessionInsightHintTimerRef.current !== null) { + window.clearTimeout(sessionInsightHintTimerRef.current) + sessionInsightHintTimerRef.current = null + } isMessageListAtBottomRef.current = true topRangeLoadLockRef.current = false bottomRangeLoadLockRef.current = false @@ -4363,6 +4581,9 @@ function ChatPage(props: ChatPageProps) { setShowJumpPopover(false) setShowDetailPanel(false) setShowGroupMembersPanel(false) + setShowGroupSummaryPanel(false) + setGroupSummaryError(null) + setGroupSummaryHint(null) setGroupMemberSearchKeyword('') setGroupMembersError(null) setGroupMembersLoadingHint('') @@ -5470,6 +5691,20 @@ function ChatPage(props: ChatPageProps) { } }, [showJumpPopover]) + useEffect(() => { + if (!showGroupSummaryDatePopover) return + const handleGlobalPointerDown = (event: MouseEvent) => { + const target = event.target as Node | null + if (!target) return + if (groupSummaryDateWrapRef.current?.contains(target)) return + setShowGroupSummaryDatePopover(false) + } + document.addEventListener('mousedown', handleGlobalPointerDown) + return () => { + document.removeEventListener('mousedown', handleGlobalPointerDown) + } + }, [showGroupSummaryDatePopover]) + useEffect(() => { if (!showJumpPopover) return const syncPosition = () => { @@ -5807,6 +6042,50 @@ function ChatPage(props: ChatPageProps) { }) }, [currentSession, isCurrentSessionPrivateSnsSupported]) + const showSessionInsightHint = useCallback((hint: { success: boolean; message: string }) => { + if (sessionInsightHintTimerRef.current !== null) { + window.clearTimeout(sessionInsightHintTimerRef.current) + sessionInsightHintTimerRef.current = null + } + setSessionInsightHint(hint) + sessionInsightHintTimerRef.current = window.setTimeout(() => { + setSessionInsightHint(null) + sessionInsightHintTimerRef.current = null + }, 5000) + }, []) + + useEffect(() => { + return () => { + if (sessionInsightHintTimerRef.current !== null) { + window.clearTimeout(sessionInsightHintTimerRef.current) + sessionInsightHintTimerRef.current = null + } + } + }, []) + + useEffect(() => { + let canceled = false + + const loadGroupSummaryConfig = () => { + void configService.getAiGroupSummaryEnabled() + .then((enabled) => { + if (!canceled) setAiGroupSummaryEnabled(enabled) + }) + .catch((error) => { + console.warn('加载群聊总结配置失败:', error) + if (!canceled) setAiGroupSummaryEnabled(false) + }) + } + + loadGroupSummaryConfig() + const handleFocus = () => loadGroupSummaryConfig() + window.addEventListener('focus', handleFocus) + return () => { + canceled = true + window.removeEventListener('focus', handleFocus) + } + }, []) + useEffect(() => { if (!standaloneSessionWindow) return setStandaloneInitialLoadRequested(false) @@ -5868,7 +6147,7 @@ function ChatPage(props: ChatPageProps) { selectSessionById ]) - // 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位) + // 监听 URL 参数中的会话/锚点(通知跳转 + 足迹/深度解析锚点定位) useEffect(() => { if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理 const params = new URLSearchParams(location.search) @@ -5883,6 +6162,11 @@ function ChatPage(props: ChatPageProps) { && jumpLocalId > 0 && Number.isFinite(jumpCreateTime) && jumpCreateTime > 0 + const hasMessageAnalysisAnchor = jumpSource === 'messageAnalysis' + && Number.isFinite(jumpLocalId) + && jumpLocalId > 0 + && Number.isFinite(jumpCreateTime) + && jumpCreateTime > 0 if (hasFootprintAnchor) { pendingFootprintJumpRef.current = { @@ -5912,6 +6196,34 @@ function ChatPage(props: ChatPageProps) { return } + if (hasMessageAnalysisAnchor) { + pendingFootprintJumpRef.current = { + sessionId: urlSessionId, + localId: jumpLocalId, + createTime: jumpCreateTime + } + if (currentSessionId !== urlSessionId) { + selectSessionById(urlSessionId) + return + } + const messageStub: Message = { + messageKey: `footprint:${urlSessionId}:${jumpCreateTime}:${jumpLocalId}`, + localId: jumpLocalId, + serverId: 0, + localType: 0, + createTime: jumpCreateTime, + sortSeq: jumpCreateTime, + isSend: null, + senderUsername: null, + parsedContent: '', + rawContent: '' + } + handleInSessionResultJump(messageStub) + pendingFootprintJumpRef.current = null + navigate('/chat', { replace: true }) + return + } + pendingFootprintJumpRef.current = null if (currentSessionId !== urlSessionId) { selectSessionById(urlSessionId) @@ -6115,6 +6427,41 @@ function ChatPage(props: ChatPageProps) { }) }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) + const handleTriggerSessionInsight = useCallback(async () => { + const session = currentSession + const sessionId = String(session?.username || currentSessionId || '').trim() + if (!sessionId || isTriggeringSessionInsight) return + + setIsTriggeringSessionInsight(true) + if (sessionInsightHintTimerRef.current !== null) { + window.clearTimeout(sessionInsightHintTimerRef.current) + sessionInsightHintTimerRef.current = null + } + setSessionInsightHint({ success: true, message: '正在生成当前聊天的 AI 见解...' }) + try { + const result = await window.electronAPI.insight.triggerSessionInsight({ + sessionId, + displayName: session?.displayName || sessionId, + avatarUrl: session?.avatarUrl + }) + if (currentSessionRef.current !== sessionId) return + showSessionInsightHint({ + success: result.success, + message: result.message || (result.success ? 'AI 见解已生成' : 'AI 见解生成失败') + }) + } catch (error) { + if (currentSessionRef.current !== sessionId) return + showSessionInsightHint({ + success: false, + message: `触发失败:${(error as Error).message || String(error)}` + }) + } finally { + if (currentSessionRef.current === sessionId) { + setIsTriggeringSessionInsight(false) + } + } + }, [currentSession, currentSessionId, isTriggeringSessionInsight, showSessionInsightHint]) + const handleGroupAnalytics = useCallback(() => { if (!currentSessionId || !isGroupChatSession(currentSessionId)) return navigate('/analytics/group', { @@ -6887,6 +7234,8 @@ function ChatPage(props: ChatPageProps) { messageKey={messageKey} isSelected={selectedMessages.has(messageKey)} onToggleSelection={handleToggleSelection} + aiMessageInsightEnabled={aiMessageInsightEnabled} + aiMessageInsightContextCount={aiMessageInsightContextCount} /> ) @@ -6906,7 +7255,9 @@ function ChatPage(props: ChatPageProps) { handleJumpToQuotedMessage, isSelectionMode, selectedMessages, - handleToggleSelection + handleToggleSelection, + aiMessageInsightEnabled, + aiMessageInsightContextCount ]) return ( @@ -7240,9 +7591,11 @@ function ChatPage(props: ChatPageProps) { isGroupChat={isCurrentSessionGroup} standaloneSessionWindow={standaloneSessionWindow} showGroupMembersPanel={showGroupMembersPanel} + showGroupSummaryPanel={showGroupSummaryPanel} showJumpPopover={showJumpPopover} showInSessionSearch={showInSessionSearch} showDetailPanel={showDetailPanel} + aiGroupSummaryEnabled={aiGroupSummaryEnabled} shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton} isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported} isExportActionBusy={isExportActionBusy} @@ -7251,10 +7604,13 @@ function ChatPage(props: ChatPageProps) { isBatchTranscribing={isBatchTranscribing} runningBatchVoiceTaskType={runningBatchVoiceTaskType} isBatchDecrypting={isBatchDecrypting} + isTriggeringSessionInsight={isTriggeringSessionInsight} isRefreshingMessages={isRefreshingMessages} isLoadingMessages={isLoadingMessages} currentSessionId={currentSessionId} jumpCalendarWrapRef={jumpCalendarWrapRef} + onTriggerSessionInsight={handleTriggerSessionInsight} + onToggleGroupSummaryPanel={toggleGroupSummaryPanel} onGroupAnalytics={handleGroupAnalytics} onToggleGroupMembersPanel={toggleGroupMembersPanel} onExportCurrentSession={handleExportCurrentSession} @@ -7299,6 +7655,20 @@ function ChatPage(props: ChatPageProps) { )} + {sessionInsightHint && ( +
+ {isTriggeringSessionInsight ? : } + {sessionInsightHint.message} +
+ )} + + {groupSummaryHint && ( +
+ {isTriggeringGroupSummary ? : } + {groupSummaryHint.message} +
+ )} + setChatSnsTimelineTarget(null)} @@ -7539,6 +7909,138 @@ function ChatPage(props: ChatPageProps) { )} + {showGroupSummaryPanel && isCurrentSessionGroup && ( +
+
+
+

AI 群聊总结

+ {currentSession?.displayName || currentSessionId} +
+ +
+ +
+
+ +
+ + setShowGroupSummaryDatePopover(false)} + currentDate={new Date(`${groupSummaryDateFilter || formatDateInputLocal(new Date())}T00:00:00`)} + onSelect={(date) => setGroupSummaryDateFilter(formatDateInputLocal(date))} + className="group-summary-calendar-popover" + maxDate={new Date()} + /> +
+ +
+ + {isGroupSummaryToday ? ( +
+ {([1, 2, 4, 8, 12, 24] as const).map((hours) => ( + + ))} +
+ ) : ( +
将按设置里的自动总结间隔切分选中日期的完整聊天记录。
+ )} + + +
+ {isGroupSummaryToday ? '少于 5 条可总结消息会自动跳过。' : '每个切片少于 5 条可总结消息会自动跳过。'} +
+
+ +
+ {groupSummaryLoading ? ( +
+ + 加载总结中... +
+ ) : groupSummaryError ? ( +
{groupSummaryError}
+ ) : groupSummaryRecords.length === 0 ? ( +
当前日期暂无群聊总结
+ ) : ( + <> +
共 {groupSummaryTotal} 条总结
+ {groupSummaryRecords.map((record) => ( +
+
+
+ {formatSummaryPeriod(record.periodStart, record.periodEnd)} + + {record.triggerType === 'manual' ? '手动' : '自动'} · {record.readableMessageCount} 条消息 + +
+ +
+
+ {record.topics.map((topic, topicIndex) => ( +
+
{topic.title}
+
+ 参与者 +

{topic.participants.length > 0 ? topic.participants.join('、') : '未明确'}

+
+
+ 关键/矛盾点 +

{topic.keyPoints.length > 0 ? topic.keyPoints.join(';') : '无'}

+
+
+ 结论 +

{topic.conclusion || '暂无明确结论'}

+
+
+ ))} +
+
+ ))} + + )} +
+
+ )} + {/* 会话详情面板 */} {showDetailPanel && (
@@ -8195,6 +8697,79 @@ function ChatPage(props: ChatPageProps) { document.body )} + {groupSummaryLogRecord && createPortal( +
setGroupSummaryLogRecord(null)}> +
e.stopPropagation()}> +
+

群聊总结日志

+ +
+
+
+
+ 群聊 + {groupSummaryLogRecord.displayName} +
+
+ 时段 + {formatSummaryPeriod(groupSummaryLogRecord.periodStart, groupSummaryLogRecord.periodEnd)} +
+
+ 触发 + {groupSummaryLogRecord.triggerType === 'manual' ? '手动' : '自动'} +
+
+ 模型 + {groupSummaryLogRecord.log.model} +
+
+ 消息数 + {groupSummaryLogRecord.log.readableMessageCount} / {groupSummaryLogRecord.log.messageCount} +
+
+ JSON Mode + + {groupSummaryLogRecord.log.responseFormatJson ? '启用' : '未启用'} + {groupSummaryLogRecord.log.responseFormatFallback ? `,已降级:${groupSummaryLogRecord.log.responseFormatFallbackReason || '未知原因'}` : ''} + +
+
+
+
+ + 系统提示词 +
+
{groupSummaryLogRecord.log.systemPrompt}
+
+
+
+ + 用户提示词与完整记录 +
+
{groupSummaryLogRecord.log.userPrompt}
+
+
+
+ + 模型输出原文 +
+
{groupSummaryLogRecord.log.rawOutput}
+
+
+
+ + 最终总结 +
+
{groupSummaryLogRecord.log.finalSummary}
+
+
+
+
, + document.body + )} + {/* 修改消息弹窗 */} {editingMessage && createPortal(
@@ -8401,6 +8976,32 @@ const senderAvatarCache = createBoundedCache<{ avatarUrl?: string; displayName?: }) const senderAvatarLoading = new Map>() +type MessageInsightAnalysis = { + explicitText: string + emotion: string + intent: string + topic: string +} + +type MessageInsightState = { + status: 'idle' | 'loading' | 'success' | 'error' + data?: MessageInsightAnalysis + error?: string + cached?: boolean + recordId?: string +} + +const messageInsightMemoryCache = new Map() + +function buildMessageInsightCacheKey(sessionId: string, message: Message, messageKey: string): string { + return [ + String(sessionId || '').trim(), + Math.floor(Number(message.localId || 0)), + Math.floor(Number(message.createTime || 0)), + messageKey + ].join(':') +} + function getSharedImageDecryptTask( key: string, createTask: () => Promise @@ -8456,6 +9057,190 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { } // 消息气泡组件 +function MessageInsightControl({ + message, + messageKey, + session, + displayName, + avatarUrl, + senderName, + targetText, + contextCount, + compact +}: { + message: Message + messageKey: string + session: ChatSession + displayName?: string + avatarUrl?: string + senderName?: string + targetText: string + contextCount: number + compact?: boolean +}) { + const anchorRef = useRef(null) + const cardRef = useRef(null) + const cacheKey = useMemo(() => buildMessageInsightCacheKey(session.username, message, messageKey), [message, messageKey, session.username]) + const [open, setOpen] = useState(false) + const [state, setState] = useState(() => messageInsightMemoryCache.get(cacheKey) || { status: 'idle' }) + const [position, setPosition] = useState<{ top: number; left: number; placement: 'top' | 'bottom' }>({ top: 0, left: 0, placement: 'top' }) + + useEffect(() => { + setState(messageInsightMemoryCache.get(cacheKey) || { status: 'idle' }) + setOpen(false) + }, [cacheKey]) + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current + if (!anchor) return + const rect = anchor.getBoundingClientRect() + const cardWidth = cardRef.current?.offsetWidth || 320 + const cardHeight = cardRef.current?.offsetHeight || 190 + const gap = 10 + const preferredTop = rect.top - cardHeight - gap + const placement: 'top' | 'bottom' = preferredTop < 8 ? 'bottom' : 'top' + const top = placement === 'top' ? preferredTop : rect.bottom + gap + const left = Math.min(Math.max(8, rect.left + 20), Math.max(8, window.innerWidth - cardWidth - 8)) + setPosition({ + top: Math.min(Math.max(8, top), Math.max(8, window.innerHeight - cardHeight - 8)), + left, + placement + }) + }, []) + + useEffect(() => { + if (!open) return + updatePosition() + const handle = () => updatePosition() + window.addEventListener('resize', handle) + window.addEventListener('scroll', handle, true) + return () => { + window.removeEventListener('resize', handle) + window.removeEventListener('scroll', handle, true) + } + }, [open, updatePosition]) + + const requestInsight = useCallback(async (forceRefresh = false) => { + if (!forceRefresh) { + const cached = messageInsightMemoryCache.get(cacheKey) + if (cached?.status === 'success') { + setState(cached) + return + } + } + setState({ status: 'loading' }) + try { + const result = await window.electronAPI.insight.generateMessageInsight({ + sessionId: session.username, + displayName: displayName || session.displayName || session.username, + avatarUrl: avatarUrl || session.avatarUrl, + targetLocalId: message.localId, + targetCreateTime: message.createTime, + targetMessageKey: messageKey, + targetText, + targetSenderName: senderName || displayName || session.displayName || session.username, + contextCount, + forceRefresh + }) + if (result.success && result.data) { + const nextState: MessageInsightState = { + status: 'success', + data: result.data, + cached: result.cached === true, + recordId: result.recordId + } + messageInsightMemoryCache.set(cacheKey, nextState) + setState(nextState) + } else { + setState({ status: 'error', error: result.message || '解析失败' }) + } + } catch (error) { + setState({ status: 'error', error: (error as Error).message || '解析失败' }) + } + }, [avatarUrl, cacheKey, contextCount, displayName, message.createTime, message.localId, messageKey, senderName, session.avatarUrl, session.displayName, session.username, targetText]) + + const handleOpen = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + setOpen(true) + window.setTimeout(updatePosition, 0) + const cached = messageInsightMemoryCache.get(cacheKey) + if (cached?.status === 'success') { + setState(cached) + return + } + void requestInsight(false) + }, [cacheKey, requestInsight, updatePosition]) + + const card = open ? createPortal( + <> + +
+
+ {state.status === 'loading' && ( +
+ + 解析中... +
+ )} + {state.status === 'error' && ( +
+ {state.error || '解析失败'} + +
+ )} + {state.status === 'success' && state.data && ( + <> +

{state.data.explicitText}

+
+
+ 情绪:{state.data.emotion} + 意图:{state.data.intent} + 话题:{state.data.topic} +
+ + )} +
+
+ , + document.body + ) : null + + return ( + <> + + {card} + + ) +} + function MessageBubble({ message, messageKey, @@ -8471,7 +9256,9 @@ function MessageBubble({ onJumpToQuotedMessage, isSelectionMode, isSelected, - onToggleSelection + onToggleSelection, + aiMessageInsightEnabled, + aiMessageInsightContextCount }: { message: Message; messageKey: string; @@ -8488,6 +9275,8 @@ function MessageBubble({ isSelectionMode?: boolean; isSelected?: boolean; onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void; + aiMessageInsightEnabled?: boolean; + aiMessageInsightContextCount?: number; }) { const isSystem = isSystemMessage(message.localType) const isEmoji = message.localType === 47 @@ -9706,6 +10495,33 @@ function MessageBubble({ const avatarUrl = isSent ? (myAvatarUrl || resolvedSenderAvatarUrl) : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) + const canShowMessageInsight = Boolean( + aiMessageInsightEnabled && + !isSent && + !isSystem && + !isImage && + !isVideo && + !isVoice && + !isEmoji && + !isCard && + !isCall && + !isType49 && + message.localType === 1 && + cleanedParsedContent.trim() + ) + const messageInsightControl = canShowMessageInsight ? ( + + ) : null // 是否有引用消息 const hasQuote = quotedContent.length > 0 @@ -11051,6 +11867,7 @@ function MessageBubble({ isSelected={isSelected} onContextMenu={onContextMenu} onToggleSelection={onToggleSelection} + actionNode={messageInsightControl} portal={systemAlertPortal} > {renderContent()} @@ -11073,6 +11890,8 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { if (prevProps.onContextMenu !== nextProps.onContextMenu) return false if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false + if (prevProps.aiMessageInsightEnabled !== nextProps.aiMessageInsightEnabled) return false + if (prevProps.aiMessageInsightContextCount !== nextProps.aiMessageInsightContextCount) return false return ( prevProps.session.username === nextProps.session.username && diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 29e34e7..84ec00a 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1177,10 +1177,18 @@ } .export-defaults-modal-actions { - padding: 0 18px 16px; + padding: 12px 18px 16px; + border-top: 1px solid color-mix(in srgb, var(--border-color) 64%, transparent); background: var(--bg-primary); } +.export-defaults-close-action { + min-width: 96px; + min-height: 38px; + justify-content: center; + border-radius: 8px; +} + .task-center-card-label { line-height: 1; white-space: nowrap; @@ -1932,10 +1940,11 @@ --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width)); --contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap)); --contacts-message-col-width: 94px; + --contacts-latest-time-col-width: 128px; --contacts-media-col-width: 58px; --contacts-action-col-width: 126px; --contacts-actions-sticky-width: 160px; - --contacts-table-min-width: 1120px; + --contacts-table-min-width: 1248px; overflow: hidden; border: none; border-radius: 8px; @@ -2184,6 +2193,58 @@ box-sizing: border-box; } + .contacts-list-header-latest-time { + width: var(--contacts-latest-time-col-width); + min-width: var(--contacts-latest-time-col-width); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + .contacts-list-header-sortable { + background: transparent; + border: none; + padding: 4px 6px; + margin: 0; + color: inherit; + font: inherit; + cursor: pointer; + border-radius: 6px; + gap: 4px; + transition: background-color 0.12s ease, color 0.12s ease; + + &:hover { + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; + } + + &.is-active { + color: var(--primary); + } + } + + .contacts-list-header-sort-icon { + color: inherit; + flex-shrink: 0; + + &.muted { + color: var(--text-tertiary); + opacity: 0.6; + } + } + .contacts-list-header-media { width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width); @@ -2501,6 +2562,37 @@ box-sizing: border-box; } + .row-latest-time { + width: var(--contacts-latest-time-col-width); + min-width: var(--contacts-latest-time-col-width); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + text-align: center; + box-sizing: border-box; + } + + .row-latest-time-value { + margin: 0; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + &.muted { + color: var(--text-tertiary); + } + } + .row-media-metric { width: var(--contacts-media-col-width); min-width: var(--contacts-media-col-width); @@ -5027,6 +5119,7 @@ --contacts-name-text-width: 10em; --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width)); --contacts-message-col-width: 94px; + --contacts-latest-time-col-width: 120px; --contacts-media-col-width: 56px; --contacts-action-col-width: 126px; } @@ -5054,6 +5147,10 @@ min-width: var(--contacts-message-col-width); } + .table-wrap .row-latest-time { + min-width: var(--contacts-latest-time-col-width); + } + .table-wrap .row-media-metric { min-width: var(--contacts-media-col-width); } @@ -5753,7 +5850,8 @@ line-height: 1.6; } -.close-icon-btn { +.automation-modal .close-icon-btn, +.automation-editor-modal .close-icon-btn { border: none; background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); color: var(--text-secondary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02e1d8e..42730bc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -4,6 +4,9 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' import { Aperture, + ArrowDown, + ArrowUp, + ArrowUpDown, Calendar, Check, CheckSquare, @@ -656,6 +659,41 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +const formatLatestMessageTimeFromSeconds = ( + timestamp?: number, + now: number = Date.now() +): { text: string; title: string } => { + if (!timestamp || !Number.isFinite(timestamp) || timestamp <= 0) { + return { text: '--', title: '' } + } + const ms = timestamp * 1000 + const absolute = formatYmdHmDateTime(ms) + const diff = Math.max(0, now - ms) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diff < minute) { + return { text: '刚刚', title: absolute } + } + if (diff < hour) { + const minutes = Math.max(1, Math.floor(diff / minute)) + return { text: `${minutes} 分钟前`, title: absolute } + } + if (diff < day) { + const hours = Math.max(1, Math.floor(diff / hour)) + return { text: `${hours} 小时前`, title: absolute } + } + return { text: absolute, title: absolute } +} + +type ContactsSortKey = 'messageCount' | 'latestMessageTime' +type ContactsSortOrder = 'desc' | 'asc' +interface ContactsSortConfig { + key: ContactsSortKey | null + order: ContactsSortOrder | null +} +const DEFAULT_CONTACTS_SORT_CONFIG: ContactsSortConfig = { key: null, order: null } + const isSingleContactSession = (sessionId: string): boolean => { const normalized = String(sessionId || '').trim() if (!normalized) return false @@ -2269,6 +2307,18 @@ function ExportPage() { const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') const [backgroundTasks, setBackgroundTasks] = useState([]) + const [contactsSortConfig, setContactsSortConfig] = useState(DEFAULT_CONTACTS_SORT_CONFIG) + + const toggleContactsSort = useCallback((key: ContactsSortKey) => { + setContactsSortConfig(prev => { + if (prev.key !== key) { + return { key, order: 'desc' } + } + if (prev.order === 'desc') return { key, order: 'asc' } + if (prev.order === 'asc') return DEFAULT_CONTACTS_SORT_CONFIG + return { key, order: 'desc' } + }) + }, []) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -4364,7 +4414,7 @@ function ExportPage() { try { if (prioritizedSessionIds.length > 0) { patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') - const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) + const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds, { bypassSessionCache: true, preferHintCache: false }) if (isStale()) return { ...accumulatedCounts } if (priorityResult.success) { applyCounts(priorityResult.counts) @@ -4381,7 +4431,7 @@ function ExportPage() { if (remainingSessionIds.length > 0) { patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') - const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) + const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds, { bypassSessionCache: true, preferHintCache: false }) if (isStale()) return { ...accumulatedCounts } if (remainingResult.success) { applyCounts(remainingResult.counts) @@ -6661,34 +6711,47 @@ function ExportPage() { ) }) - const indexedContacts = contacts.map((contact, index) => ({ - contact, - index, - count: (() => { - const counted = normalizeMessageCount(sessionMessageCounts[contact.username]) - if (typeof counted === 'number') return counted - const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint) - return hinted - })() - })) + const indexedContacts = contacts.map((contact, index) => { + const sessionRow = sessionRowByUsername.get(contact.username) + const counted = normalizeMessageCount(sessionMessageCounts[contact.username]) + const hinted = normalizeMessageCount(sessionRow?.messageCountHint) + const count = typeof counted === 'number' ? counted : hinted + const rowTs = sessionRow?.lastTimestamp || sessionRow?.sortTimestamp + const latestTime = typeof rowTs === 'number' && rowTs > 0 ? rowTs : undefined + return { contact, index, count, latestTime } + }) + + const compareNullable = (a: number | undefined, b: number | undefined, order: ContactsSortOrder): number => { + const aHas = typeof a === 'number' && Number.isFinite(a) + const bHas = typeof b === 'number' && Number.isFinite(b) + if (aHas && bHas) { + const diff = (a as number) - (b as number) + return order === 'desc' ? -diff : diff + } + if (aHas) return -1 + if (bHas) return 1 + return 0 + } + + const sortKey = contactsSortConfig.key + const sortOrder = contactsSortConfig.order ?? 'desc' indexedContacts.sort((a, b) => { - const aHasCount = typeof a.count === 'number' - const bHasCount = typeof b.count === 'number' - if (aHasCount && bHasCount) { - const diff = (b.count as number) - (a.count as number) + if (sortKey === 'latestMessageTime') { + const diff = compareNullable(a.latestTime, b.latestTime, sortOrder) + if (diff !== 0) return diff + } else if (sortKey === 'messageCount') { + const diff = compareNullable(a.count, b.count, sortOrder) + if (diff !== 0) return diff + } else { + const diff = compareNullable(a.count, b.count, 'desc') if (diff !== 0) return diff - } else if (aHasCount) { - return -1 - } else if (bHasCount) { - return 1 } - // 无统计值或同分时保持原顺序,避免列表频繁跳动。 return a.index - b.index }) return indexedContacts.map(item => item.contact) - }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername]) + }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername, contactsSortConfig]) const keywordMatchedContactUsernameSet = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase() @@ -6897,7 +6960,7 @@ function ExportPage() { useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) - }, [activeTab, searchKeyword]) + }, [activeTab, searchKeyword, contactsSortConfig]) const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { if (sourceContacts.length === 0) return [] @@ -7613,12 +7676,29 @@ function ExportPage() { scheduleSessionMutualFriendsWorker() } + // 记录刷新前的会话时间戳 + const oldTimestamps = new Map( + sessionsRef.current.map(s => [s.username, s.lastTimestamp || s.sortTimestamp || 0]) + ) + await Promise.all([ loadContactsList({ scopeKey }), loadSnsStats({ full: true }), loadSnsUserPostCounts({ force: true }) ]) + // 找出有变动的会话(最后消息时间变化) + const changedSessions = sessionsRef.current.filter(session => { + const oldTs = oldTimestamps.get(session.username) || 0 + const newTs = session.lastTimestamp || session.sortTimestamp || 0 + return newTs > oldTs + }) + + // 只对有变动的会话重新加载消息数量 + if (changedSessions.length > 0) { + await loadSessionMessageCounts(changedSessions, activeTabRef.current, { scopeKey }) + } + const currentDetailSessionId = showSessionDetailPanel ? String(sessionDetail?.wxid || '').trim() : '' @@ -8391,6 +8471,15 @@ function ExportPage() { const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages const mediaMetric = sessionContentMetrics[contact.username] + const rowLatestTs = matchedSession?.lastTimestamp || matchedSession?.sortTimestamp + const resolvedLatestTs = typeof rowLatestTs === 'number' && rowLatestTs > 0 ? rowLatestTs : undefined + const latestTimeInfo = formatLatestMessageTimeFromSeconds(resolvedLatestTs, nowTick) + const latestTimeState: { state: 'value'; text: string; title: string } | { state: 'loading' } | { state: 'na'; text: '--' } = + !canExport + ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) + : (typeof resolvedLatestTs === 'number' && resolvedLatestTs > 0 + ? { state: 'value', text: latestTimeInfo.text, title: latestTimeInfo.title } + : { state: 'na', text: '--' }) const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } = !canExport ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) @@ -8506,6 +8595,18 @@ function ExportPage() { )}
+
+ {latestTimeState.state === 'loading' + ? + : ( + + {latestTimeState.text} + + )} +
{emojiMetric.state === 'loading' @@ -9250,7 +9351,7 @@ function ExportPage() {
+ 表情包 语音 图片 diff --git a/src/pages/InsightInboxPage.scss b/src/pages/InsightInboxPage.scss index b2c5484..764ab48 100644 --- a/src/pages/InsightInboxPage.scss +++ b/src/pages/InsightInboxPage.scss @@ -265,6 +265,25 @@ color: #5b55a0; background: rgba(99, 102, 241, 0.12); } + + &.manual { + color: #0f766e; + background: rgba(20, 184, 166, 0.13); + } +} + +.insight-source-pill { + padding: 5px 8px; + border-radius: 999px; + background: rgba(91, 147, 144, 0.1); + color: var(--primary); + font-size: 12px; + white-space: nowrap; + + &.message_analysis { + background: rgba(245, 158, 11, 0.13); + color: #8a5a00; + } } .insight-time { @@ -282,6 +301,43 @@ word-break: break-word; } +.message-analysis-target { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} + +.message-analysis-target-label { + flex: 0 0 auto; + color: var(--text-tertiary); + font-weight: 700; +} + +.message-analysis-target-text { + min-width: 0; + word-break: break-word; +} + +.message-analysis-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; + + span { + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 7px; + font-size: 12px; + } +} + .insight-filter-panel { width: var(--insight-panel-width); flex-shrink: 0; @@ -376,6 +432,28 @@ } } +.insight-source-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + + button { + min-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; diff --git a/src/pages/InsightInboxPage.tsx b/src/pages/InsightInboxPage.tsx index 6466fce..5310039 100644 --- a/src/pages/InsightInboxPage.tsx +++ b/src/pages/InsightInboxPage.tsx @@ -7,6 +7,7 @@ import type { InsightRecordContactFacet, InsightRecordFilters, InsightRecordListResult, + InsightRecordSourceType, InsightRecordSummary, InsightRecordTriggerReason } from '../types/electron' @@ -15,6 +16,7 @@ import './InsightInboxPage.scss' const INSIGHT_AVATAR_URL = './assets/insight/AI_Insight.png' type DateFilterMode = 'all' | 'today' | 'week' | 'custom' +type SourceFilterMode = InsightRecordSourceType | 'all' function getStartOfDay(date: Date): number { const next = new Date(date) @@ -62,16 +64,23 @@ function formatGroupDate(timestamp: number): string { } function getTriggerLabel(reason: InsightRecordTriggerReason): string { + if (reason === 'message_analysis') return '深度解析' if (reason === 'silence') return '沉默提醒' if (reason === 'test') return '测试见解' + if (reason === 'manual') return '手动触发' return '活跃分析' } +function getSourceLabel(sourceType?: InsightRecordSourceType): string { + return sourceType === 'message_analysis' ? '深度解析' : 'AI 见解' +} + function buildLogText(record: InsightRecord): string { const log = record.log - return [ + const lines = [ `时间:${new Date(record.createdAt).toLocaleString('zh-CN')}`, `联系人:${record.displayName} (${record.sessionId})`, + `来源:${getSourceLabel(record.sourceType)}`, `触发类型:${getTriggerLabel(record.triggerReason)}`, `接口地址:${log.endpoint}`, `模型:${log.model}`, @@ -90,7 +99,23 @@ function buildLogText(record: InsightRecord): string { '', '最终见解:', log.finalInsight - ].join('\n') + ] + + if (record.sourceType === 'message_analysis') { + lines.splice(8, 0, + `JSON Mode:${log.responseFormatJson ? '启用' : '未启用'}`, + `JSON Mode 降级:${log.responseFormatFallback ? '是' : '否'}`, + `降级原因:${log.responseFormatFallbackReason || '无'}`, + `上下文:请求 ${log.contextStats?.requested ?? log.contextCount} 条,前 ${log.contextStats?.beforeTarget ?? 0} 条,后 ${log.contextStats?.afterTarget ?? 0} 条`, + `上下文读取异常:${log.contextStats?.readError || '无'}` + ) + lines.splice(4, 0, + `目标消息:${record.messageInsight?.targetSenderName || log.targetMessage?.senderName || ''}:${record.messageInsight?.targetTextPreview || log.targetMessage?.textPreview || ''}`, + `目标定位:localId=${record.messageInsight?.targetLocalId || log.targetMessage?.localId || 0}, createTime=${record.messageInsight?.targetCreateTime || log.targetMessage?.createTime || 0}, key=${record.messageInsight?.targetMessageKey || log.targetMessage?.messageKey || ''}` + ) + } + + return lines.join('\n') } export default function InsightInboxPage() { @@ -101,6 +126,7 @@ export default function InsightInboxPage() { const [keyword, setKeyword] = useState('') const [contactSearch, setContactSearch] = useState('') const [selectedSessionId, setSelectedSessionId] = useState('') + const [sourceType, setSourceType] = useState('all') const [dateMode, setDateMode] = useState('all') const [customStart, setCustomStart] = useState(formatDateInput(new Date())) const [customEnd, setCustomEnd] = useState(formatDateInput(new Date())) @@ -133,11 +159,12 @@ export default function InsightInboxPage() { const filters = useMemo(() => ({ keyword: keyword.trim() || undefined, sessionId: selectedSessionId || undefined, + sourceType, startTime: dateRange.startTime, endTime: dateRange.endTime, limit: 200, offset: 0 - }), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId]) + }), [dateRange.endTime, dateRange.startTime, keyword, selectedSessionId, sourceType]) const loadRecords = useCallback(async () => { setLoading(true) @@ -200,6 +227,16 @@ export default function InsightInboxPage() { }, [contactSearch, contacts]) const openChat = (record: InsightRecordSummary) => { + if (record.sourceType === 'message_analysis' && record.messageInsight) { + const query = new URLSearchParams({ + sessionId: record.sessionId, + jumpSource: 'messageAnalysis', + jumpLocalId: String(record.messageInsight.targetLocalId || 0), + jumpCreateTime: String(record.messageInsight.targetCreateTime || 0) + }) + navigate(`/chat?${query.toString()}`) + return + } navigate(`/chat?sessionId=${encodeURIComponent(record.sessionId)}`) } @@ -305,6 +342,7 @@ export default function InsightInboxPage() {
+ {getSourceLabel(record.sourceType)} {getTriggerLabel(record.triggerReason)} {formatRecordTime(record.createdAt)}
+ {record.sourceType === 'message_analysis' && record.messageInsight && ( +
+ 目标消息 + + {record.messageInsight.targetSenderName}:{record.messageInsight.targetTextPreview} + +
+ )}

{record.insight}

+ {record.sourceType === 'message_analysis' && record.messageInsight && ( +
+ 情绪:{record.messageInsight.analysis.emotion} + 意图:{record.messageInsight.analysis.intent} + 话题:{record.messageInsight.analysis.topic} +
+ )} ))} @@ -347,6 +400,28 @@ export default function InsightInboxPage() { +
+
+ + 来源类型 +
+
+ {[ + { value: 'all', label: '全部' }, + { value: 'insight', label: 'AI 见解' }, + { value: 'message_analysis', label: '深度解析' } + ].map((option) => ( + + ))} +
+
+
@@ -440,9 +515,44 @@ export default function InsightInboxPage() { `Max Tokens: ${logRecord.log.maxTokens}`, `Temperature: ${logRecord.log.temperature}`, `Duration: ${logRecord.log.durationMs}ms`, - `Trigger: ${getTriggerLabel(logRecord.triggerReason)}` + `Source: ${getSourceLabel(logRecord.sourceType)}`, + `Trigger: ${getTriggerLabel(logRecord.triggerReason)}`, + ...(logRecord.sourceType === 'message_analysis' + ? [ + `JSON Mode: ${logRecord.log.responseFormatJson ? 'enabled' : 'disabled'}`, + `JSON Fallback: ${logRecord.log.responseFormatFallback ? 'yes' : 'no'}`, + `Fallback Reason: ${logRecord.log.responseFormatFallbackReason || 'none'}` + ] + : []) ].join('\n')} + {logRecord.sourceType === 'message_analysis' && ( +
+

深度解析目标

+
{[
+                    `Sender: ${logRecord.messageInsight?.targetSenderName || logRecord.log.targetMessage?.senderName || ''}`,
+                    `Preview: ${logRecord.messageInsight?.targetTextPreview || logRecord.log.targetMessage?.textPreview || ''}`,
+                    `LocalId: ${logRecord.messageInsight?.targetLocalId || logRecord.log.targetMessage?.localId || 0}`,
+                    `CreateTime: ${logRecord.messageInsight?.targetCreateTime || logRecord.log.targetMessage?.createTime || 0}`,
+                    `MessageKey: ${logRecord.messageInsight?.targetMessageKey || logRecord.log.targetMessage?.messageKey || ''}`,
+                    `Context Requested: ${logRecord.log.contextStats?.requested ?? logRecord.log.contextCount}`,
+                    `Context Before: ${logRecord.log.contextStats?.beforeTarget ?? 0}`,
+                    `Context After: ${logRecord.log.contextStats?.afterTarget ?? 0}`,
+                    `Context Error: ${logRecord.log.contextStats?.readError || 'none'}`
+                  ].join('\n')}
+
+ )} + {logRecord.sourceType === 'message_analysis' && logRecord.log.parsedAnalysis && ( +
+

解析字段

+
{[
+                    `explicitText: ${logRecord.log.parsedAnalysis.explicitText}`,
+                    `emotion: ${logRecord.log.parsedAnalysis.emotion}`,
+                    `intent: ${logRecord.log.parsedAnalysis.intent}`,
+                    `topic: ${logRecord.log.parsedAnalysis.topic}`
+                  ].join('\n')}
+
+ )}

System Prompt

{logRecord.log.systemPrompt}
diff --git a/src/pages/MyFootprintPage.tsx b/src/pages/MyFootprintPage.tsx index ff7918d..cb4c194 100644 --- a/src/pages/MyFootprintPage.tsx +++ b/src/pages/MyFootprintPage.tsx @@ -770,12 +770,12 @@ function MyFootprintPage() { <>
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 363b04a..552d060 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -3390,14 +3390,17 @@ } &.insight-social-tab { - --insight-moments-column-width: 76px; - --insight-social-column-width: minmax(220px, 300px); - --insight-status-column-width: 82px; - --insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-social-column-width) var(--insight-status-column-width); + --insight-moments-column-width: 44px; + --insight-profile-column-width: 78px; + --insight-social-column-width: minmax(184px, 230px); + --insight-status-column-width: 70px; + --insight-social-list-grid: minmax(0, 1fr) var(--insight-moments-column-width) var(--insight-profile-column-width) var(--insight-social-column-width) var(--insight-status-column-width); .anti-revoke-list-header { grid-template-columns: var(--insight-social-list-grid); - gap: 14px; + gap: 8px; + padding-left: 14px; + padding-right: 10px; .insight-moments-column-title { display: flex; @@ -3405,6 +3408,12 @@ color: var(--text-tertiary); } + .insight-profile-column-title { + display: flex; + justify-content: center; + color: var(--text-tertiary); + } + .insight-social-column-title { min-width: 0; color: var(--text-tertiary); @@ -3420,11 +3429,19 @@ display: grid; grid-template-columns: var(--insight-social-list-grid); align-items: center; - gap: 14px; + gap: 8px; + padding-left: 14px; + padding-right: 10px; } .anti-revoke-row-main { min-width: 0; + gap: 8px; + + .anti-revoke-check { + width: 16px; + height: 16px; + } } .insight-moments-cell { @@ -3435,6 +3452,92 @@ min-height: 30px; } + .insight-profile-cell { + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + min-height: 30px; + } + + .insight-profile-status-btn { + position: relative; + width: 74px; + height: 28px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + font-size: 12px; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, opacity 0.16s ease; + + &:disabled { + cursor: not-allowed; + opacity: 0.48; + } + + &:not(:disabled)::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%) translateY(2px); + z-index: 8; + max-width: 180px; + width: max-content; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--text-primary) 92%, #000 8%); + color: var(--bg-primary); + font-size: 12px; + line-height: 1.2; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease; + } + + &:not(:disabled):hover::after, + &:not(:disabled):focus-visible::after { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); + } + + .profile-status-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 86%, transparent); + flex-shrink: 0; + } + + &.ready { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + } + + &.running { + color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary)); + } + + &.failed { + color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%); + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary)); + } + } + .insight-moments-toggle { position: relative; width: 18px; @@ -3489,24 +3592,33 @@ } .insight-social-binding-cell { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 6px; + flex-wrap: wrap; + } + + .insight-social-binding-controls { min-width: 0; display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px 10px; + grid-template-columns: minmax(92px, 1fr) auto; + gap: 6px; align-items: center; + flex: 1 1 100%; } .insight-social-binding-input-wrap { min-width: 0; display: flex; align-items: center; - gap: 8px; + gap: 5px; } .binding-platform-chip { flex-shrink: 0; border-radius: 999px; - padding: 2px 7px; + padding: 2px 6px; font-size: 11px; color: var(--text-secondary); border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); @@ -3536,12 +3648,18 @@ display: inline-flex; align-items: center; justify-self: flex-end; - gap: 8px; + gap: 4px; + + .btn { + min-width: 0; + padding-left: 8px; + padding-right: 8px; + } } .insight-social-binding-feedback { - grid-column: 1 / span 2; min-height: 18px; + flex: 1 1 100%; } .binding-feedback { @@ -3563,6 +3681,11 @@ align-items: flex-end; max-width: none; min-width: 0; + + .status-badge { + padding-left: 8px; + padding-right: 8px; + } } } @@ -3605,6 +3728,7 @@ grid-template-columns: minmax(0, 1fr) auto; .insight-moments-column-title, + .insight-profile-column-title, .insight-social-column-title { display: none; } @@ -3617,12 +3741,14 @@ } .insight-moments-cell, + .insight-profile-cell, .insight-social-binding-cell, .anti-revoke-row-status { width: 100%; } - .insight-moments-cell { + .insight-moments-cell, + .insight-profile-cell { justify-content: flex-start; } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 92c0f78..1af36dc 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,12 +1,15 @@ import { useState, useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' +import { useMemo } from 'react' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' +import groupSummaryPrompt from '../../shared/groupSummaryPrompt.json' import type { ChatSession, ContactInfo } from '../types/models' +import type { InsightProfileStatus } from '../types/electron' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, @@ -32,9 +35,11 @@ type SettingsTab = | 'aiCommon' | 'insight' | 'aiFootprint' + | 'aiGroupSummary' + | 'aiMessageInsight' | 'autoDownload' -const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [ +const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, @@ -56,16 +61,19 @@ const filteredTabs = tabs.filter(tab => { return true }) -const aiTabs: Array<{ id: Extract; label: string }> = [ +const aiTabs: Array<{ id: Extract; label: string }> = [ { id: 'aiCommon', label: '基础配置' }, { id: 'insight', label: 'AI 见解' }, - { id: 'aiFootprint', label: 'AI 足迹' } + { id: 'aiFootprint', label: 'AI 足迹' }, + { id: 'aiGroupSummary', label: '群聊总结' }, + { id: 'aiMessageInsight', label: '消息解析' } ] const isMac = navigator.userAgent.toLowerCase().includes('mac') const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' +const DEFAULT_GROUP_SUMMARY_SYSTEM_PROMPT = String(groupSummaryPrompt.defaultSystemPrompt || '').trim() const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录' const dbPathPlaceholder = isMac @@ -325,8 +333,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [weiboBindingDrafts, setWeiboBindingDrafts] = useState>({}) const [weiboBindingErrors, setWeiboBindingErrors] = useState>({}) const [weiboBindingLoadingSessionId, setWeiboBindingLoadingSessionId] = useState(null) + const [aiInsightProfileStatuses, setAiInsightProfileStatuses] = useState>({}) + const [aiInsightProfileActiveSessionId, setAiInsightProfileActiveSessionId] = useState(null) const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') + const [aiGroupSummaryEnabled, setAiGroupSummaryEnabled] = useState(false) + const [aiGroupSummaryIntervalHours, setAiGroupSummaryIntervalHours] = useState(4) + const [aiGroupSummarySystemPrompt, setAiGroupSummarySystemPrompt] = useState('') + const [aiGroupSummaryFilterList, setAiGroupSummaryFilterList] = useState([]) + const [aiGroupSummaryFilterSearchKeyword, setAiGroupSummaryFilterSearchKeyword] = useState('') + const [aiMessageInsightEnabled, setAiMessageInsightEnabled] = useState(false) + const [aiMessageInsightContextCount, setAiMessageInsightContextCount] = useState(50) + const [aiMessageInsightSystemPrompt, setAiMessageInsightSystemPrompt] = useState('') // 自动下载图片 const [autoDownloadStatus, setAutoDownloadStatus] = useState<{ isHooked: boolean; pid: number | null; supported: boolean } | null>(null) @@ -372,7 +390,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { }, [location.state]) useEffect(() => { - if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') { + if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint' || activeTab === 'aiGroupSummary' || activeTab === 'aiMessageInsight') { setAiGroupExpanded(true) } }, [activeTab]) @@ -590,6 +608,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiInsightWeiboBindings = await configService.getAiInsightWeiboBindings() const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() + const savedAiGroupSummaryEnabled = await configService.getAiGroupSummaryEnabled() + const savedAiGroupSummaryIntervalHours = await configService.getAiGroupSummaryIntervalHours() + const savedAiGroupSummarySystemPrompt = await configService.getAiGroupSummarySystemPrompt() + const savedAiGroupSummaryFilterList = await configService.getAiGroupSummaryFilterList() + const savedAiMessageInsightEnabled = await configService.getAiMessageInsightEnabled() + const savedAiMessageInsightContextCount = await configService.getAiMessageInsightContextCount() + const savedAiMessageInsightSystemPrompt = await configService.getAiMessageInsightSystemPrompt() setAiInsightEnabled(savedAiInsightEnabled) setAiModelApiBaseUrl(savedAiModelApiBaseUrl) @@ -616,6 +641,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiInsightWeiboBindings(savedAiInsightWeiboBindings) setAiFootprintEnabled(savedAiFootprintEnabled) setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) + setAiGroupSummaryEnabled(savedAiGroupSummaryEnabled) + setAiGroupSummaryIntervalHours(savedAiGroupSummaryIntervalHours) + setAiGroupSummarySystemPrompt(savedAiGroupSummarySystemPrompt) + setAiGroupSummaryFilterList(savedAiGroupSummaryFilterList) + setAiMessageInsightEnabled(savedAiMessageInsightEnabled) + setAiMessageInsightContextCount(savedAiMessageInsightContextCount) + setAiMessageInsightSystemPrompt(savedAiMessageInsightSystemPrompt) } catch (e: any) { console.error('加载配置失败:', e) @@ -2833,37 +2865,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage('已清空主动推送过滤列表', true) } - const sessionFilterOptionMap = new Map() + const { sessionFilterOptionMap, sessionFilterOptions } = useMemo(() => { + const optionMap = new Map() - for (const session of chatSessions) { - if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue - sessionFilterOptionMap.set(session.username, { - username: session.username, - displayName: session.displayName || session.username, - avatarUrl: session.avatarUrl, - type: getSessionFilterType(session) - }) - } + for (const session of chatSessions) { + if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue + optionMap.set(session.username, { + username: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl, + type: getSessionFilterType(session) + }) + } - for (const contact of messagePushContactOptions) { - if (!contact.username) continue - if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue - const existing = sessionFilterOptionMap.get(contact.username) - sessionFilterOptionMap.set(contact.username, { - username: contact.username, - displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, - avatarUrl: existing?.avatarUrl || contact.avatarUrl, - type: getSessionFilterType(contact) - }) - } + for (const contact of messagePushContactOptions) { + if (!contact.username) continue + if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue + const existing = optionMap.get(contact.username) + optionMap.set(contact.username, { + username: contact.username, + displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, + avatarUrl: existing?.avatarUrl || contact.avatarUrl, + type: getSessionFilterType(contact) + }) + } - const sessionFilterOptions = Array.from(sessionFilterOptionMap.values()) - .sort((a, b) => { - const aSession = chatSessions.find(session => session.username === a.username) - const bSession = chatSessions.find(session => session.username === b.username) - return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) - - Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) - }) + const options = Array.from(optionMap.values()) + .sort((a, b) => { + const aSession = chatSessions.find(session => session.username === a.username) + const bSession = chatSessions.find(session => session.username === b.username) + return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) - + Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) + }) + + return { sessionFilterOptionMap: optionMap, sessionFilterOptions: options } + }, [chatSessions, messagePushContactOptions]) const getSessionFilterOptionInfo = (username: string) => { return sessionFilterOptionMap.get(username) || { @@ -2903,6 +2939,15 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { messagePushFilterSearchKeyword ) + const groupSummaryFilterOptions = sessionFilterOptions.filter((session) => session.type === 'group') + const groupSummaryAvailableSessions = groupSummaryFilterOptions.filter((session) => { + const keyword = aiGroupSummaryFilterSearchKeyword.trim().toLowerCase() + if (aiGroupSummaryFilterList.includes(session.username)) return false + if (!keyword) return true + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + }) + const handleAddAllNotificationFilterSessions = async () => { const usernames = notificationAvailableSessions.map(session => session.username) if (usernames.length === 0) return @@ -3231,6 +3276,120 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { await configService.setAiInsightWeiboBindings(nextBindings) if (!silent) showMessage('已清除微博绑定', true) } + + const refreshAiInsightProfileStatuses = async (sessionIds?: string[]) => { + const ids = normalizeSessionIds( + sessionIds || sessionFilterOptions + .filter((session) => session.type === 'private') + .map((session) => session.username) + ) + if (ids.length === 0) { + setAiInsightProfileStatuses({}) + setAiInsightProfileActiveSessionId(null) + return + } + try { + const result = await window.electronAPI.insight.listProfileStatuses(ids) + if (!result.success) return + setAiInsightProfileStatuses(result.statuses || {}) + setAiInsightProfileActiveSessionId(result.activeTask?.sessionId || null) + } catch (error) { + console.warn('刷新 AI 画像状态失败:', error) + } + } + + const handleGenerateInsightProfile = async (session: SessionFilterOption) => { + const sessionId = session.username + const currentStatus = aiInsightProfileStatuses[sessionId] + if (currentStatus?.status === 'running') { + try { + const result = await window.electronAPI.insight.cancelProfile(sessionId) + showMessage(result.message || '已请求取消画像任务', result.success) + } catch (e: any) { + showMessage(`取消画像失败:${e?.message || String(e)}`, false) + } finally { + setTimeout(() => { void refreshAiInsightProfileStatuses() }, 500) + } + return + } + + if (aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) return + + setAiInsightProfileStatuses((prev) => ({ + ...prev, + [sessionId]: { + sessionId, + status: 'running', + phase: '正在初始化画像...', + updatedAt: Date.now() + } + })) + setAiInsightProfileActiveSessionId(sessionId) + try { + const result = await window.electronAPI.insight.generateProfile({ + sessionId, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl + }) + showMessage(result.message || (result.success ? '画像完成' : '画像失败'), result.success) + } catch (e: any) { + showMessage(`画像失败:${e?.message || String(e)}`, false) + } finally { + await refreshAiInsightProfileStatuses() + } + } + + useEffect(() => { + if (activeTab !== 'insight') return + const ids = sessionFilterOptions + .filter((session) => session.type === 'private') + .map((session) => session.username) + if (ids.length === 0) return + void refreshAiInsightProfileStatuses(ids) + const timer = window.setInterval(() => { + void refreshAiInsightProfileStatuses(ids) + }, 2500) + return () => window.clearInterval(timer) + }, [activeTab, sessionFilterOptions]) + + const getInsightProfileButtonMeta = (sessionId: string) => { + const status = aiInsightProfileStatuses[sessionId] + const activeOther = Boolean(aiInsightProfileActiveSessionId && aiInsightProfileActiveSessionId !== sessionId) + if (status?.status === 'running') { + return { + className: 'running', + label: '取消', + title: status.phase || '画像生成中,点击取消', + disabled: false, + icon: + } + } + if (status?.status === 'ready') { + return { + className: 'ready', + label: '已画像', + title: activeOther ? '其他联系人正在画像中' : '点击以重新画像', + disabled: activeOther, + icon: + } + } + if (status?.status === 'failed') { + return { + className: 'failed', + label: '失败', + title: activeOther ? '其他联系人正在画像中' : (status.error || '画像失败,点击重试'), + disabled: activeOther, + icon: + } + } + return { + className: 'none', + label: '未画像', + title: activeOther ? '其他联系人正在画像中' : '点击进行画像', + disabled: activeOther, + icon: