diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a72f4b3..1127108 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig(() => { alias: { '@': resolve('src/'), '~': resolve('src/'), + '@openchatlab': resolve('packages'), }, }, plugins: [ diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index b6a2229..965c51a 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -646,7 +646,10 @@ export async function* chatStream(messages: ChatMessage[], options?: ChatOptions const errorInfo = extractErrorInfo(error) const errorStr = `${errorInfo.name || 'Error'}: ${errorInfo.message}` - aiLogger.error('LLM', `Stream request failed | config: ${configStr} | received: ${chunkCount} chunks/${totalContent.length} chars`) + aiLogger.error( + 'LLM', + `Stream request failed | config: ${configStr} | received: ${chunkCount} chunks/${totalContent.length} chars` + ) aiLogger.error('LLM', `Error: ${errorStr}`) // 堆栈单独一行 diff --git a/electron/main/ai/rag/pipeline/semantic.ts b/electron/main/ai/rag/pipeline/semantic.ts index 4f41be0..a825043 100644 --- a/electron/main/ai/rag/pipeline/semantic.ts +++ b/electron/main/ai/rag/pipeline/semantic.ts @@ -224,7 +224,10 @@ export async function executeSemanticPipeline(options: SemanticPipelineOptions): const topResults = scoredResults.slice(0, topK) const topScore = topResults[0]?.score ?? 0 - logger.info('RAG', `✅ Semantic search done: returned ${topResults.length} results, top relevance ${(topScore * 100).toFixed(1)}%`) + logger.info( + 'RAG', + `✅ Semantic search done: returned ${topResults.length} results, top relevance ${(topScore * 100).toFixed(1)}%` + ) // 7. 生成证据块 const evidenceBlock = formatEvidenceBlock(rewrittenQuery, topResults) diff --git a/electron/main/i18n/locales/zh-CN.ts b/electron/main/i18n/locales/zh-CN.ts index 5420823..ea64b36 100644 --- a/electron/main/i18n/locales/zh-CN.ts +++ b/electron/main/i18n/locales/zh-CN.ts @@ -63,10 +63,8 @@ export default { month: '筛选指定月份的消息(1-12),需要配合 year 使用', day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - start_time: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - end_time: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', }, }, get_recent_messages: { @@ -77,10 +75,8 @@ export default { month: '筛选指定月份的消息(1-12),需要配合 year 使用', day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - start_time: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - end_time: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', }, }, get_member_stats: { @@ -118,10 +114,8 @@ export default { month: '筛选指定月份的消息(1-12),需要配合 year 使用', day: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', hour: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - start_time: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - end_time: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', }, }, get_message_context: { diff --git a/electron/main/index.ts b/electron/main/index.ts index f9dafeb..3700f87 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -9,6 +9,10 @@ import { needsLegacyMigration, migrateFromLegacyDir, ensureAppDirs, cleanupPendi import { migrateAllDatabases, checkMigrationNeeded } from './database/core' import { initLocale } from './i18n' +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 统一通过扩展类型访问退出标记,避免使用 @ts-ignore。 +const appWithQuitFlag = app as AppWithQuitFlag + class MainProcess { mainWindow: BrowserWindow | null constructor() { @@ -254,8 +258,7 @@ class MainProcess { // 只有显式调用quit才退出系统,区分MAC系统程序坞退出和点击X隐藏 app.on('before-quit', () => { - // @ts-ignore - app.isQuiting = true + appWithQuitFlag.isQuiting = true }) // 退出前清理资源 @@ -272,7 +275,9 @@ class MainProcess { } this.mainWindow.webContents.on('did-finish-load', () => { setTimeout(() => { - this.mainWindow && this.mainWindow.webContents.send('app-started') + if (this.mainWindow) { + this.mainWindow.webContents.send('app-started') + } }, 500) }) @@ -288,8 +293,7 @@ class MainProcess { this.mainWindow.on('close', (event) => { if (platform.isMacOS) { // macOS: 只有明确退出时才真正关闭,否则只隐藏窗口(符合 macOS 用户习惯) - // @ts-ignore - if (!app.isQuiting) { + if (!appWithQuitFlag.isQuiting) { event.preventDefault() this.mainWindow?.hide() } diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index a2aaf2f..8021a46 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -215,16 +215,20 @@ export function registerChatHandlers(ctx: IpcContext): void { message: '', }) - const result = await worker.streamImport(filePath, (progress: ParseProgress) => { - win.webContents.send('chat:importProgress', { - stage: progress.stage, - progress: progress.percentage, - message: progress.message, - bytesRead: progress.bytesRead, - totalBytes: progress.totalBytes, - messagesProcessed: progress.messagesProcessed, - }) - }, formatOptions) + const result = await worker.streamImport( + filePath, + (progress: ParseProgress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + }, + formatOptions + ) if (result.success) { console.log('[IpcMain] Stream import (with options) successful, sessionId:', result.sessionId) @@ -492,21 +496,6 @@ export function registerChatHandlers(ctx: IpcContext): void { return parser.getSupportedFormats() }) - /** - * 获取复读分析数据 - */ - ipcMain.handle( - 'chat:getRepeatAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getRepeatAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get repeat analysis:', error) - return { originators: [], initiators: [], breakers: [], totalRepeatChains: 0 } - } - } - ) - /** * 获取口头禅分析数据 */ @@ -522,58 +511,6 @@ export function registerChatHandlers(ctx: IpcContext): void { } ) - /** - * 获取夜猫分析数据 - */ - ipcMain.handle( - 'chat:getNightOwlAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getNightOwlAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get night owl analysis:', error) - return { - nightOwlRank: [], - lastSpeakerRank: [], - firstSpeakerRank: [], - consecutiveRecords: [], - champions: [], - totalDays: 0, - } - } - } - ) - - /** - * 获取龙王分析数据 - */ - ipcMain.handle( - 'chat:getDragonKingAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getDragonKingAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get top poster analysis:', error) - return { rank: [], totalDays: 0 } - } - } - ) - - /** - * 获取潜水分析数据 - */ - ipcMain.handle( - 'chat:getDivingAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getDivingAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get lurker analysis:', error) - return { rank: [] } - } - } - ) - /** * 获取 @ 互动分析数据 */ @@ -663,45 +600,6 @@ export function registerChatHandlers(ctx: IpcContext): void { } ) - /** - * 获取斗图分析数据 - */ - ipcMain.handle( - 'chat:getMemeBattleAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMemeBattleAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get meme battle analysis:', error) - return { - longestBattle: null, - rankByCount: [], - rankByImageCount: [], - totalBattles: 0, - } - } - } - ) - - /** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ - ipcMain.handle( - 'chat:getCheckInAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getCheckInAnalysis(sessionId, filter) - } catch (error) { - console.error('Failed to get check-in analysis:', error) - return { - streakRank: [], - loyaltyRank: [], - totalDays: 0, - } - } - } - ) - // ==================== 成员管理 ==================== /** @@ -777,6 +675,32 @@ export function registerChatHandlers(ctx: IpcContext): void { } }) + // ==================== 插件系统 ==================== + + /** + * 插件参数化只读 SQL 查询 + */ + ipcMain.handle('chat:pluginQuery', async (_, sessionId: string, sql: string, params: any[]) => { + try { + return await worker.pluginQuery(sessionId, sql, params) + } catch (error) { + console.error('[IpcMain] Plugin query failed:', error) + throw error + } + }) + + /** + * 插件计算卸载(纯函数在 Worker 中执行) + */ + ipcMain.handle('chat:pluginCompute', async (_, fnString: string, input: any) => { + try { + return await worker.pluginCompute(fnString, input) + } catch (error) { + console.error('[IpcMain] Plugin compute failed:', error) + throw error + } + }) + // ==================== SQL 实验室 ==================== /** @@ -887,7 +811,12 @@ export function registerChatHandlers(ctx: IpcContext): void { ipcMain.handle( 'session:generateSummary', async (_, dbSessionId: string, chatSessionId: number, locale?: string, forceRegenerate?: boolean) => { - console.log('[IPC] session:generateSummary request received:', { dbSessionId, chatSessionId, locale, forceRegenerate }) + console.log('[IPC] session:generateSummary request received:', { + dbSessionId, + chatSessionId, + locale, + forceRegenerate, + }) try { const { generateSessionSummary } = await import('../ai/summary') const result = await generateSessionSummary( diff --git a/electron/main/ipc/window.ts b/electron/main/ipc/window.ts index b390ab9..7158d23 100644 --- a/electron/main/ipc/window.ts +++ b/electron/main/ipc/window.ts @@ -2,12 +2,16 @@ * 窗口和文件系统操作 IPC 处理器 */ -import { ipcMain, app, dialog, clipboard, shell } from 'electron' +import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron' import * as fs from 'fs/promises' import type { IpcContext } from './types' import { simulateUpdateDialog, manualCheckForUpdates } from '../update' import { t } from '../i18n' +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。 +const appWithQuitFlag = app as AppWithQuitFlag + /** * 注册窗口和文件系统操作 IPC 处理器 */ @@ -22,7 +26,11 @@ export function registerWindowHandlers(ctx: IpcContext): void { ipcMain.on('window-maxOrRestore', (ev) => { const winSizeState = win.isMaximized() - winSizeState ? win.restore() : win.maximize() + if (winSizeState) { + win.restore() + } else { + win.maximize() + } ev.reply('windowState', win.isMaximized()) }) @@ -36,8 +44,7 @@ export function registerWindowHandlers(ctx: IpcContext): void { ipcMain.on('window-close', () => { win.close() - // @ts-ignore - app.isQuitting = true + appWithQuitFlag.isQuiting = true app.quit() }) @@ -56,7 +63,6 @@ export function registerWindowHandlers(ctx: IpcContext): void { // 设置主题模式 ipcMain.on('window:setThemeSource', (_, mode: 'system' | 'light' | 'dark') => { - const { nativeTheme } = require('electron') nativeTheme.themeSource = mode // Windows 上动态更新 overlay 颜色以匹配主题 diff --git a/electron/main/merger/index.ts b/electron/main/merger/index.ts index 019d6f2..590ece8 100644 --- a/electron/main/merger/index.ts +++ b/electron/main/merger/index.ts @@ -192,8 +192,12 @@ function detectConflictsInMessages( console.log(`[Merger] Conflict #${conflicts.length + 1}:`) console.log(` Timestamp: ${ts} (${new Date(ts * 1000).toLocaleString()})`) console.log(` Sender: ${sender} (${item1.msg.senderName})`) - console.log(` File1: ${item1.source}, length: ${content1.length}, content: "${content1.slice(0, 50)}..."`) - console.log(` File2: ${item2.source}, length: ${content2.length}, content: "${content2.slice(0, 50)}..."`) + console.log( + ` File1: ${item1.source}, length: ${content1.length}, content: "${content1.slice(0, 50)}..."` + ) + console.log( + ` File2: ${item2.source}, length: ${content2.length}, content: "${content2.slice(0, 50)}..."` + ) } conflicts.push({ @@ -441,7 +445,9 @@ export async function mergeFilesWithTempDb( totalProcessed += messages.length }) - console.log(`[Merger] Processing ${source}: ${readerCount} unique messages, elapsed: ${Date.now() - readerStartTime}ms`) + console.log( + `[Merger] Processing ${source}: ${readerCount} unique messages, elapsed: ${Date.now() - readerStartTime}ms` + ) } // 排序 diff --git a/electron/main/parser/formats/chatlab-jsonl.ts b/electron/main/parser/formats/chatlab-jsonl.ts index 0564637..65cc1c9 100644 --- a/electron/main/parser/formats/chatlab-jsonl.ts +++ b/electron/main/parser/formats/chatlab-jsonl.ts @@ -179,14 +179,16 @@ async function* parseChatLabJsonl(options: ParseOptions): AsyncGenerator((resolve, reject) => { const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) - const pipeline = chain([ - readStream, - parser(), - streamValues(), - ]) + const pipeline = chain([readStream, parser(), streamValues()]) let found = false diff --git a/electron/main/parser/formats/telegram-native.ts b/electron/main/parser/formats/telegram-native.ts index d9200c4..e3e64a5 100644 --- a/electron/main/parser/formats/telegram-native.ts +++ b/electron/main/parser/formats/telegram-native.ts @@ -33,12 +33,7 @@ import type { ParsedMessage, } from '../types' import { getFileSize, createProgress } from '../utils' -import { - mapChatType, - extractPlatformId, - detectMessageType, - buildContent, -} from './utils/telegram-utils' +import { mapChatType, extractPlatformId, detectMessageType, buildContent } from './utils/telegram-utils' import type { TelegramChat } from './utils/telegram-utils' // ==================== 类型定义 ==================== @@ -67,9 +62,7 @@ export const feature: FormatFeature = { extensions: ['.json'], signatures: { // Telegram 导出 JSON 的特征(语言无关:品牌名在所有语言导出中都存在) - head: [ - /Telegram/i, - ], + head: [/Telegram/i], // 注意:personal_information 在某些导出配置中是可选的,不能作为必需字段 requiredFields: ['chats'], }, @@ -93,12 +86,7 @@ export async function scanChats(filePath: string): Promise { // 使用 stream-json 解析 chats.list 数组中的每个聊天对象 // ignore 过滤掉 messages 的实际内容以加速扫描 - const pipeline = chain([ - readStream, - parser(), - pick({ filter: /^chats\.list\.\d+$/ }), - streamValues(), - ]) + const pipeline = chain([readStream, parser(), pick({ filter: /^chats\.list\.\d+$/ }), streamValues()]) pipeline.on('data', ({ value }: { value: TelegramChat }) => { const chat = value diff --git a/electron/main/parser/formats/utils/telegram-utils.ts b/electron/main/parser/formats/utils/telegram-utils.ts index de22d36..a9a310a 100644 --- a/electron/main/parser/formats/utils/telegram-utils.ts +++ b/electron/main/parser/formats/utils/telegram-utils.ts @@ -127,7 +127,9 @@ export function buildContent(msg: TelegramMessage): string | null { const action = msg.action || '' const members = msg.members?.join(', ') || '' if (members) return `[${action}] ${members}` - return `[${action}]` || text || null + // action 为空时回退到文本,避免常量表达式触发 lint 规则。 + if (action) return `[${action}]` + return text || null } // 贴纸:使用 emoji 表示 diff --git a/electron/main/parser/formats/whatsapp-native-txt.ts b/electron/main/parser/formats/whatsapp-native-txt.ts index b17322c..297ad27 100644 --- a/electron/main/parser/formats/whatsapp-native-txt.ts +++ b/electron/main/parser/formats/whatsapp-native-txt.ts @@ -78,7 +78,7 @@ export const feature: FormatFeature = { */ function cleanLine(line: string): string { // 移除常见的不可见字符:BOM、LTR Mark、RTL Mark、零宽字符等 - return line.replace(/^[\uFEFF\u200E\u200F\u200B\u200C\u200D\u2060]+/, '').trim() + return line.replace(/^(?:\uFEFF|\u200E|\u200F|\u200B|\u200C|\u200D|\u2060)+/, '').trim() } // ==================== 消息头正则 ==================== @@ -162,9 +162,7 @@ function parseWhatsAppTime(timeStr: string, isV2Format: boolean = false): number if (isV2Format) { // 方括号格式:先移除可能的逗号 const normalizedStr = timeStr.replace(',', '') - const match = normalizedStr.match( - /^(\d{1,4})\/(\d{1,2})\/(\d{2,4}) (\d{1,2}):(\d{2}):(\d{2})$/ - ) + const match = normalizedStr.match(/^(\d{1,4})\/(\d{1,2})\/(\d{2,4}) (\d{1,2}):(\d{2}):(\d{2})$/) if (match) { const [, part1, part2, part3, hour, minute, second] = match let year: number, month: number, day: number @@ -186,14 +184,7 @@ function parseWhatsAppTime(timeStr: string, isV2Format: boolean = false): number year = 2000 + parseInt(part3, 10) } - const date = new Date( - year, - month - 1, - day, - parseInt(hour, 10), - parseInt(minute, 10), - parseInt(second, 10) - ) + const date = new Date(year, month - 1, day, parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10)) return Math.floor(date.getTime() / 1000) } } diff --git a/electron/main/update.ts b/electron/main/update.ts index 6fce259..8725544 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -6,6 +6,10 @@ import { getActiveProxyUrl } from './network/proxy' import { closeWorkerAsync } from './worker/workerManager' import { t } from './i18n' +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 更新安装流程会主动触发退出,这里使用类型扩展存储退出标记。 +const appWithQuitFlag = app as AppWithQuitFlag + // R2 镜像源 URL(速度更快,作为主要更新源) const R2_MIRROR_URL = 'https://chatlab.1app.top/releases/download' @@ -136,7 +140,9 @@ const checkUpdate = (win) => { // 预发布版本仅在手动检查时显示更新弹窗 if (isPreRelease && !isManualCheck) { console.log(`[Update] Pre-release version found: ${info.version}, skipping auto-update prompt`) - logger.info(`[Update] Pre-release version found: ${info.version}, skipping auto-update prompt (manual check required)`) + logger.info( + `[Update] Pre-release version found: ${info.version}, skipping auto-update prompt (manual check required)` + ) return } @@ -189,8 +195,7 @@ const checkUpdate = (win) => { .then(async (result) => { if (result.response === 0) { win.webContents.send('begin-install') - // @ts-ignore - app.isQuiting = true + appWithQuitFlag.isQuiting = true // Windows 上先关闭 Worker 线程,确保进程能正常退出 // 否则 NSIS 安装器可能无法关闭旧进程 diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index f7f0d19..604007f 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -25,17 +25,11 @@ import { getMemberNameHistory, getAllSessions, getSession, - getRepeatAnalysis, getCatchphraseAnalysis, - getNightOwlAnalysis, - getDragonKingAnalysis, - getDivingAnalysis, getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph, - getMemeBattleAnalysis, - getCheckInAnalysis, searchMessages, getMessageContext, getRecentMessages, @@ -51,6 +45,7 @@ import { // SQL 实验室 executeRawSQL, getSchema, + executePluginQuery, // 会话索引 generateSessions, generateIncrementalSessions, @@ -117,17 +112,11 @@ const syncHandlers: Record any> = { deleteMember: (p) => deleteMember(p.sessionId, p.memberId), // 高级分析 - getRepeatAnalysis: (p) => getRepeatAnalysis(p.sessionId, p.filter), getCatchphraseAnalysis: (p) => getCatchphraseAnalysis(p.sessionId, p.filter), - getNightOwlAnalysis: (p) => getNightOwlAnalysis(p.sessionId, p.filter), - getDragonKingAnalysis: (p) => getDragonKingAnalysis(p.sessionId, p.filter), - getDivingAnalysis: (p) => getDivingAnalysis(p.sessionId, p.filter), getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter), getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options), - getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter), - getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter), // AI 查询 searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId), @@ -142,6 +131,13 @@ const syncHandlers: Record any> = { executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), getSchema: (p) => getSchema(p.sessionId), + // 插件系统 + pluginQuery: (p) => executePluginQuery(p.sessionId, p.sql, p.params), + pluginCompute: (p: { fnString: string; input: any }) => { + const fn = new Function('return ' + p.fnString)() + return fn(p.input) + }, + // 会话索引 generateSessions: (p) => generateSessions(p.sessionId, p.gapThreshold), generateIncrementalSessions: (p) => generateIncrementalSessions(p.sessionId, p.gapThreshold), diff --git a/electron/main/worker/import/streamImport.ts b/electron/main/worker/import/streamImport.ts index 9fca512..d6eac05 100644 --- a/electron/main/worker/import/streamImport.ts +++ b/electron/main/worker/import/streamImport.ts @@ -173,9 +173,7 @@ async function streamImportWithFallback( const candidate = candidates[i] const isLast = i === candidates.length - 1 - console.log( - `[StreamImport] Trying format ${i + 1}/${candidates.length}: ${candidate.name} (${candidate.id})` - ) + console.log(`[StreamImport] Trying format ${i + 1}/${candidates.length}: ${candidate.name} (${candidate.id})`) const result = await streamImportSingle(filePath, requestId, candidate, formatOptions) @@ -194,9 +192,7 @@ async function streamImportWithFallback( } // 当前格式解析失败(0 消息),尝试下一个 - console.log( - `[StreamImport] Format ${candidate.name} produced 0 messages, falling back to next candidate...` - ) + console.log(`[StreamImport] Format ${candidate.name} produced 0 messages, falling back to next candidate...`) } // 不应该到这里,但以防万一 @@ -380,218 +376,222 @@ async function streamImportSingle( logInfo('Starting streamParseFile...') try { - await streamParseFile(actualFilePath, { - batchSize: 5000, - formatOptions, + await streamParseFile( + actualFilePath, + { + batchSize: 5000, + formatOptions, - onProgress: (progress) => { - callbackStats.onProgressCalls++ - // 转发进度到主进程 - sendProgress(requestId, progress) - }, + onProgress: (progress) => { + callbackStats.onProgressCalls++ + // 转发进度到主进程 + sendProgress(requestId, progress) + }, - onLog: (level, message) => { - callbackStats.onLogCalls++ - // 将解析器日志写入导入日志文件 - if (level === 'error') { - logError(message) - } else { - logInfo(message) - } - }, - - onMeta: (meta: ParsedMeta) => { - callbackStats.onMetaCalls++ - if (!metaInserted) { - logInfo(`Writing meta: name=${meta.name}, type=${meta.type}, platform=${meta.platform}`) - insertMeta.run( - meta.name, - meta.platform, - meta.type, - Math.floor(Date.now() / 1000), - meta.groupId || null, - meta.groupAvatar || null, - meta.ownerId || null - ) - metaInserted = true - } - }, - - onMembers: (members: ParsedMember[]) => { - callbackStats.onMembersCalls++ - callbackStats.totalMembersReceived += members.length - logInfo(`Received member batch: ${members.length} members`) - for (const member of members) { - insertMember.run( - member.platformId, - member.accountName || null, - member.groupNickname || null, - member.avatar || null, - member.roles ? JSON.stringify(member.roles) : '[]' - ) - const row = getMemberId.get(member.platformId) as { id: number } | undefined - if (row) { - memberIdMap.set(member.platformId, row.id) + onLog: (level, message) => { + callbackStats.onLogCalls++ + // 将解析器日志写入导入日志文件 + if (level === 'error') { + logError(message) + } else { + logInfo(message) } - } - }, + }, - onMessageBatch: (messages: ParsedMessage[]) => { - callbackStats.onMessageBatchCalls++ - callbackStats.totalMessagesReceived += messages.length - // 每收到 10 批消息记录一次日志 - if (callbackStats.onMessageBatchCalls <= 3 || callbackStats.onMessageBatchCalls % 10 === 0) { - logInfo(`Received message batch #${callbackStats.onMessageBatchCalls}: ${messages.length} messages`) - } + onMeta: (meta: ParsedMeta) => { + callbackStats.onMetaCalls++ + if (!metaInserted) { + logInfo(`Writing meta: name=${meta.name}, type=${meta.type}, platform=${meta.platform}`) + insertMeta.run( + meta.name, + meta.platform, + meta.type, + Math.floor(Date.now() / 1000), + meta.groupId || null, + meta.groupAvatar || null, + meta.ownerId || null + ) + metaInserted = true + } + }, - // 分阶段计时 - let memberLookupTime = 0 - let memberInsertTime = 0 - let messageInsertTime = 0 - let nicknameTrackTime = 0 - let memberLookupCount = 0 - let memberInsertCount = 0 - let nicknameChangeCount = 0 - - for (const msg of messages) { - // 数据验证:跳过无效消息(带统计) - if (!msg.senderPlatformId) { - callbackStats.skippedNoSenderId++ - continue - } - if (!msg.senderAccountName) { - callbackStats.skippedNoAccountName++ - continue - } - if (msg.timestamp === undefined || msg.timestamp === null || isNaN(msg.timestamp)) { - callbackStats.skippedInvalidTimestamp++ - continue - } - if (msg.type === undefined || msg.type === null) { - callbackStats.skippedNoType++ - continue - } - - // 确保成员存在 - let t0 = Date.now() - if (!memberIdMap.has(msg.senderPlatformId)) { - // 消息中没有头像和角色信息,设为默认值 + onMembers: (members: ParsedMember[]) => { + callbackStats.onMembersCalls++ + callbackStats.totalMembersReceived += members.length + logInfo(`Received member batch: ${members.length} members`) + for (const member of members) { insertMember.run( - msg.senderPlatformId, + member.platformId, + member.accountName || null, + member.groupNickname || null, + member.avatar || null, + member.roles ? JSON.stringify(member.roles) : '[]' + ) + const row = getMemberId.get(member.platformId) as { id: number } | undefined + if (row) { + memberIdMap.set(member.platformId, row.id) + } + } + }, + + onMessageBatch: (messages: ParsedMessage[]) => { + callbackStats.onMessageBatchCalls++ + callbackStats.totalMessagesReceived += messages.length + // 每收到 10 批消息记录一次日志 + if (callbackStats.onMessageBatchCalls <= 3 || callbackStats.onMessageBatchCalls % 10 === 0) { + logInfo(`Received message batch #${callbackStats.onMessageBatchCalls}: ${messages.length} messages`) + } + + // 分阶段计时 + let memberLookupTime = 0 + let memberInsertTime = 0 + let messageInsertTime = 0 + let nicknameTrackTime = 0 + let memberLookupCount = 0 + let memberInsertCount = 0 + let nicknameChangeCount = 0 + + for (const msg of messages) { + // 数据验证:跳过无效消息(带统计) + if (!msg.senderPlatformId) { + callbackStats.skippedNoSenderId++ + continue + } + if (!msg.senderAccountName) { + callbackStats.skippedNoAccountName++ + continue + } + if (msg.timestamp === undefined || msg.timestamp === null || isNaN(msg.timestamp)) { + callbackStats.skippedInvalidTimestamp++ + continue + } + if (msg.type === undefined || msg.type === null) { + callbackStats.skippedNoType++ + continue + } + + // 确保成员存在 + let t0 = Date.now() + if (!memberIdMap.has(msg.senderPlatformId)) { + // 消息中没有头像和角色信息,设为默认值 + insertMember.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + null, + '[]' + ) + const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined + if (row) { + memberIdMap.set(msg.senderPlatformId, row.id) + } + memberInsertCount++ + memberInsertTime += Date.now() - t0 + } else { + memberLookupCount++ + memberLookupTime += Date.now() - t0 + } + + const senderId = memberIdMap.get(msg.senderPlatformId) + if (senderId === undefined) continue + + // 插入消息 + // 防御性处理:确保所有值都是 SQLite 兼容的类型 + // SQLite 只支持: numbers, strings, bigints, buffers, null + let safeContent: string | null = null + if (msg.content != null) { + safeContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + } + + t0 = Date.now() + insertMessage.run( + senderId, msg.senderAccountName || null, msg.senderGroupNickname || null, - null, - '[]' + msg.timestamp, + msg.type, + safeContent, + msg.replyToMessageId || null, + msg.platformMessageId || null ) - const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined - if (row) { - memberIdMap.set(msg.senderPlatformId, row.id) + messageInsertTime += Date.now() - t0 + messageCountInBatch++ + totalMessageCount++ + + // 追踪昵称变化(仅记录,不写入数据库,最后批量处理) + t0 = Date.now() + + // 追踪 account_name 变化 + const accountName = msg.senderAccountName + if (accountName && accountName !== msg.senderPlatformId) { + const tracker = accountNameTracker.get(msg.senderPlatformId) + if (!tracker) { + accountNameTracker.set(msg.senderPlatformId, { + currentName: accountName, + lastSeenTs: msg.timestamp, + history: [{ name: accountName, startTs: msg.timestamp }], + }) + nicknameChangeCount++ + } else if (tracker.currentName !== accountName) { + tracker.history.push({ name: accountName, startTs: msg.timestamp }) + tracker.currentName = accountName + tracker.lastSeenTs = msg.timestamp + nicknameChangeCount++ + } else { + tracker.lastSeenTs = msg.timestamp + } } - memberInsertCount++ - memberInsertTime += Date.now() - t0 - } else { - memberLookupCount++ - memberLookupTime += Date.now() - t0 - } - const senderId = memberIdMap.get(msg.senderPlatformId) - if (senderId === undefined) continue + // 追踪 group_nickname 变化 + const groupNickname = msg.senderGroupNickname + if (groupNickname) { + const tracker = groupNicknameTracker.get(msg.senderPlatformId) + if (!tracker) { + groupNicknameTracker.set(msg.senderPlatformId, { + currentName: groupNickname, + lastSeenTs: msg.timestamp, + history: [{ name: groupNickname, startTs: msg.timestamp }], + }) + nicknameChangeCount++ + } else if (tracker.currentName !== groupNickname) { + tracker.history.push({ name: groupNickname, startTs: msg.timestamp }) + tracker.currentName = groupNickname + tracker.lastSeenTs = msg.timestamp + nicknameChangeCount++ + } else { + tracker.lastSeenTs = msg.timestamp + } + } - // 插入消息 - // 防御性处理:确保所有值都是 SQLite 兼容的类型 - // SQLite 只支持: numbers, strings, bigints, buffers, null - let safeContent: string | null = null - if (msg.content != null) { - safeContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) - } + nicknameTrackTime += Date.now() - t0 - t0 = Date.now() - insertMessage.run( - senderId, - msg.senderAccountName || null, - msg.senderGroupNickname || null, - msg.timestamp, - msg.type, - safeContent, - msg.replyToMessageId || null, - msg.platformMessageId || null - ) - messageInsertTime += Date.now() - t0 - messageCountInBatch++ - totalMessageCount++ + // 分批提交(每 50000 条) + if (messageCountInBatch >= BATCH_COMMIT_SIZE) { + // 记录详细分阶段耗时 + const detail = + `[Detail] Member lookup: ${memberLookupTime}ms (${memberLookupCount} times) | ` + + `Member insert: ${memberInsertTime}ms (${memberInsertCount} times) | ` + + `Message insert: ${messageInsertTime}ms | ` + + `Nickname tracking: ${nicknameTrackTime}ms (${nicknameChangeCount} changes)` + logPerfDetail(detail) - // 追踪昵称变化(仅记录,不写入数据库,最后批量处理) - t0 = Date.now() + commitAndBeginNew() + messageCountInBatch = 0 - // 追踪 account_name 变化 - const accountName = msg.senderAccountName - if (accountName && accountName !== msg.senderPlatformId) { - const tracker = accountNameTracker.get(msg.senderPlatformId) - if (!tracker) { - accountNameTracker.set(msg.senderPlatformId, { - currentName: accountName, - lastSeenTs: msg.timestamp, - history: [{ name: accountName, startTs: msg.timestamp }], - }) - nicknameChangeCount++ - } else if (tracker.currentName !== accountName) { - tracker.history.push({ name: accountName, startTs: msg.timestamp }) - tracker.currentName = accountName - tracker.lastSeenTs = msg.timestamp - nicknameChangeCount++ - } else { - tracker.lastSeenTs = msg.timestamp + // 重置计时 + memberLookupTime = 0 + memberInsertTime = 0 + messageInsertTime = 0 + nicknameTrackTime = 0 + memberLookupCount = 0 + memberInsertCount = 0 + nicknameChangeCount = 0 } } - - // 追踪 group_nickname 变化 - const groupNickname = msg.senderGroupNickname - if (groupNickname) { - const tracker = groupNicknameTracker.get(msg.senderPlatformId) - if (!tracker) { - groupNicknameTracker.set(msg.senderPlatformId, { - currentName: groupNickname, - lastSeenTs: msg.timestamp, - history: [{ name: groupNickname, startTs: msg.timestamp }], - }) - nicknameChangeCount++ - } else if (tracker.currentName !== groupNickname) { - tracker.history.push({ name: groupNickname, startTs: msg.timestamp }) - tracker.currentName = groupNickname - tracker.lastSeenTs = msg.timestamp - nicknameChangeCount++ - } else { - tracker.lastSeenTs = msg.timestamp - } - } - - nicknameTrackTime += Date.now() - t0 - - // 分批提交(每 50000 条) - if (messageCountInBatch >= BATCH_COMMIT_SIZE) { - // 记录详细分阶段耗时 - const detail = - `[Detail] Member lookup: ${memberLookupTime}ms (${memberLookupCount} times) | ` + - `Member insert: ${memberInsertTime}ms (${memberInsertCount} times) | ` + - `Message insert: ${messageInsertTime}ms | ` + - `Nickname tracking: ${nicknameTrackTime}ms (${nicknameChangeCount} changes)` - logPerfDetail(detail) - - commitAndBeginNew() - messageCountInBatch = 0 - - // 重置计时 - memberLookupTime = 0 - memberInsertTime = 0 - messageInsertTime = 0 - nicknameTrackTime = 0 - memberLookupCount = 0 - memberInsertCount = 0 - nicknameChangeCount = 0 - } - } + }, }, - }, formatFeature.id) + formatFeature.id + ) // 提交最后的消息事务 if (inTransaction) { diff --git a/electron/main/worker/query/advanced/activity.ts b/electron/main/worker/query/advanced/activity.ts deleted file mode 100644 index 1c3d307..0000000 --- a/electron/main/worker/query/advanced/activity.ts +++ /dev/null @@ -1,615 +0,0 @@ -/** - * 活跃度分析模块 - * 包含:夜猫分析、龙王分析、潜水分析、打卡分析 - */ - -import { openDatabase, buildTimeFilter, buildSystemMessageFilter, type TimeFilter } from '../../core' - -// ==================== 夜猫分析 ==================== - -/** - * 根据深夜发言数获取称号 - */ -function getNightOwlTitleByCount(count: number): string { - if (count === 0) return '养生达人' - if (count <= 20) return '偶尔失眠' - if (count <= 50) return '经常失眠' - if (count <= 100) return '夜猫子' - if (count <= 200) return '秃头预备役' - if (count <= 500) return '修仙练习生' - return '守夜冠军' -} - -/** - * 将时间戳转换为"调整后的日期"(以凌晨5点为界) - */ -function getAdjustedDate(ts: number): string { - const date = new Date(ts * 1000) - const hour = date.getHours() - - if (hour < 5) { - date.setDate(date.getDate() - 1) - } - - return date.toISOString().split('T')[0] -} - -/** - * 格式化分钟数为 HH:MM - */ -function formatMinutes(minutes: number): string { - const h = Math.floor(minutes / 60) - const m = Math.round(minutes % 60) - return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}` -} - -/** - * 获取夜猫分析数据 - */ -export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - nightOwlRank: [], - lastSpeakerRank: [], - firstSpeakerRank: [], - consecutiveRecords: [], - champions: [], - totalDays: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - ts: number - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - const memberInfo = new Map() - const nightStats = new Map< - number, - { - total: number - h23: number - h0: number - h1: number - h2: number - h3to4: number - totalMessages: number - } - >() - const dailyMessages = new Map>() - const memberNightDays = new Map>() - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - const date = new Date(msg.ts * 1000) - const hour = date.getHours() - const minute = date.getMinutes() - const adjustedDate = getAdjustedDate(msg.ts) - - if (!nightStats.has(msg.senderId)) { - nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 }) - } - const stats = nightStats.get(msg.senderId)! - stats.totalMessages++ - - if (hour === 23) { - stats.h23++ - stats.total++ - } else if (hour === 0) { - stats.h0++ - stats.total++ - } else if (hour === 1) { - stats.h1++ - stats.total++ - } else if (hour === 2) { - stats.h2++ - stats.total++ - } else if (hour >= 3 && hour < 5) { - stats.h3to4++ - stats.total++ - } - - if (hour >= 23 || hour < 5) { - if (!memberNightDays.has(msg.senderId)) { - memberNightDays.set(msg.senderId, new Set()) - } - memberNightDays.get(msg.senderId)!.add(adjustedDate) - } - - if (!dailyMessages.has(adjustedDate)) { - dailyMessages.set(adjustedDate, []) - } - dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute }) - } - - const totalDays = dailyMessages.size - - // 构建修仙排行榜 - const nightOwlRank: any[] = [] - for (const [memberId, stats] of nightStats.entries()) { - if (stats.total === 0) continue - const info = memberInfo.get(memberId)! - nightOwlRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - totalNightMessages: stats.total, - title: getNightOwlTitleByCount(stats.total), - hourlyBreakdown: { - h23: stats.h23, - h0: stats.h0, - h1: stats.h1, - h2: stats.h2, - h3to4: stats.h3to4, - }, - percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0, - }) - } - nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages) - - // 最晚/最早发言 - const lastSpeakerStats = new Map() - const firstSpeakerStats = new Map() - - for (const [, dayMessages] of dailyMessages.entries()) { - if (dayMessages.length === 0) continue - - const lastMsg = dayMessages[dayMessages.length - 1] - if (!lastSpeakerStats.has(lastMsg.senderId)) { - lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] }) - } - const lastStats = lastSpeakerStats.get(lastMsg.senderId)! - lastStats.count++ - lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute) - - const firstMsg = dayMessages[0] - if (!firstSpeakerStats.has(firstMsg.senderId)) { - firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] }) - } - const firstStats = firstSpeakerStats.get(firstMsg.senderId)! - firstStats.count++ - firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute) - } - - // 构建排行 - const lastSpeakerRank: any[] = [] - for (const [memberId, stats] of lastSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const maxMinutes = Math.max(...stats.times) - lastSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(maxMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - lastSpeakerRank.sort((a, b) => b.count - a.count) - - const firstSpeakerRank: any[] = [] - for (const [memberId, stats] of firstSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const minMinutes = Math.min(...stats.times) - firstSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(minMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - firstSpeakerRank.sort((a, b) => b.count - a.count) - - // 连续修仙天数 - const consecutiveRecords: any[] = [] - - for (const [memberId, nightDaysSet] of memberNightDays.entries()) { - if (nightDaysSet.size === 0) continue - - const info = memberInfo.get(memberId)! - const sortedDays = Array.from(nightDaysSet).sort() - - let maxStreak = 1 - let currentStreak = 1 - - for (let i = 1; i < sortedDays.length; i++) { - const prevDate = new Date(sortedDays[i - 1]) - const currDate = new Date(sortedDays[i]) - const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) - - if (diffDays === 1) { - currentStreak++ - maxStreak = Math.max(maxStreak, currentStreak) - } else { - currentStreak = 1 - } - } - - const lastDay = sortedDays[sortedDays.length - 1] - const today = new Date().toISOString().split('T')[0] - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] - const isCurrentStreak = lastDay === today || lastDay === yesterday - - consecutiveRecords.push({ - memberId, - platformId: info.platformId, - name: info.name, - maxConsecutiveDays: maxStreak, - currentStreak: isCurrentStreak ? currentStreak : 0, - }) - } - consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays) - - // 综合排名 - const championScores = new Map() - - for (const item of nightOwlRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.nightMessages = item.totalNightMessages - } - - for (const item of lastSpeakerRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.lastSpeakerCount = item.count - } - - for (const item of consecutiveRecords) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays - } - - const champions: any[] = [] - for (const [memberId, scores] of championScores.entries()) { - const info = memberInfo.get(memberId)! - const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20 - if (score > 0) { - champions.push({ - memberId, - platformId: info.platformId, - name: info.name, - score, - nightMessages: scores.nightMessages, - lastSpeakerCount: scores.lastSpeakerCount, - consecutiveDays: scores.consecutiveDays, - }) - } - } - champions.sort((a, b) => b.score - a.score) - - return { - nightOwlRank, - lastSpeakerRank, - firstSpeakerRank, - consecutiveRecords, - champions, - totalDays, - } -} - -// ==================== 龙王分析 ==================== - -/** - * 获取龙王排名 - */ -export function getDragonKingAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { rank: [], totalDays: 0 } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const dailyTopSpeakers = db - .prepare( - ` - WITH daily_counts AS ( - SELECT - strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, - msg.sender_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - COUNT(*) as msg_count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY date, msg.sender_id - ), - daily_max AS ( - SELECT date, MAX(msg_count) as max_count - FROM daily_counts - GROUP BY date - ) - SELECT dc.sender_id, dc.platform_id, dc.name, COUNT(*) as dragon_days - FROM daily_counts dc - JOIN daily_max dm ON dc.date = dm.date AND dc.msg_count = dm.max_count - GROUP BY dc.sender_id - ORDER BY dragon_days DESC - ` - ) - .all(...params) as Array<{ - sender_id: number - platform_id: string - name: string - dragon_days: number - }> - - const totalDaysRow = db - .prepare( - ` - SELECT COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ` - ) - .get(...params) as { total: number } - - const totalDays = totalDaysRow.total - - const rank = dailyTopSpeakers.map((item) => ({ - memberId: item.sender_id, - platformId: item.platform_id, - name: item.name, - count: item.dragon_days, - percentage: totalDays > 0 ? Math.round((item.dragon_days / totalDays) * 10000) / 100 : 0, - })) - - return { rank, totalDays } -} - -// ==================== 潜水分析 ==================== - -/** - * 获取潜水排名 - */ -export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { rank: [] } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const lastMessages = db - .prepare( - ` - SELECT - m.id as member_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - MAX(msg.ts) as last_ts - FROM member m - JOIN message msg ON m.id = msg.sender_id - ${clauseWithSystem.replace('msg.', 'msg.')} - GROUP BY m.id - ORDER BY last_ts ASC - ` - ) - .all(...params) as Array<{ - member_id: number - platform_id: string - name: string - last_ts: number - }> - - const now = Math.floor(Date.now() / 1000) - - const rank = lastMessages.map((item) => ({ - memberId: item.member_id, - platformId: item.platform_id, - name: item.name, - lastMessageTs: item.last_ts, - daysSinceLastMessage: Math.floor((now - item.last_ts) / 86400), - })) - - return { rank } -} - -// ==================== 打卡分析 ==================== - -/** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ -export function getCheckInAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - streakRank: [], - loyaltyRank: [], - totalDays: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const whereClause = buildSystemMessageFilter(clause) - - // 1. 获取每个成员每天是否发言的数据 - // 检查时间戳格式:如果 ts > 1e12 则是毫秒,否则是秒 - const sampleTs = db.prepare(`SELECT ts FROM message LIMIT 1`).get() as { ts: number } | undefined - const tsIsMillis = sampleTs?.ts && sampleTs.ts > 1e12 - const tsExpr = tsIsMillis ? 'msg.ts / 1000' : 'msg.ts' - - const dailyActivity = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - DATE(${tsExpr}, 'unixepoch', 'localtime') as day - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - GROUP BY msg.sender_id, day - ORDER BY msg.sender_id, day - ` - ) - .all(...params) as Array<{ - senderId: number - name: string - day: string - }> - - if (dailyActivity.length === 0) return emptyResult - - // 2. 获取群聊总天数 - const allDays = new Set(dailyActivity.map((r) => r.day)) - const totalDays = allDays.size - - // 获取最后一天(用于判断当前连续) - const sortedDays = Array.from(allDays).sort() - const lastDay = sortedDays[sortedDays.length - 1] - - // 3. 按成员分组 - const memberDays = new Map }>() - for (const record of dailyActivity) { - if (!memberDays.has(record.senderId)) { - memberDays.set(record.senderId, { name: record.name, days: new Set() }) - } - memberDays.get(record.senderId)!.days.add(record.day) - } - - // 4. 计算每个成员的连续发言和累计发言 - const streakData: Array<{ - memberId: number - name: string - maxStreak: number - maxStreakStart: string - maxStreakEnd: string - currentStreak: number - }> = [] - - const loyaltyData: Array<{ - memberId: number - name: string - totalDays: number - }> = [] - - for (const [memberId, data] of memberDays) { - const sortedMemberDays = Array.from(data.days).sort() - const totalMemberDays = sortedMemberDays.length - - // 计算最长连续 - let maxStreak = 1 - let maxStreakStart = sortedMemberDays[0] - let maxStreakEnd = sortedMemberDays[0] - - let currentStreakCount = 1 - let currentStreakStart = sortedMemberDays[0] - - for (let i = 1; i < sortedMemberDays.length; i++) { - const prevDate = new Date(sortedMemberDays[i - 1]) - const currDate = new Date(sortedMemberDays[i]) - const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) - - if (diffDays === 1) { - // 连续 - currentStreakCount++ - } else { - // 中断,检查是否更新最大值 - if (currentStreakCount > maxStreak) { - maxStreak = currentStreakCount - maxStreakStart = currentStreakStart - maxStreakEnd = sortedMemberDays[i - 1] - } - currentStreakCount = 1 - currentStreakStart = sortedMemberDays[i] - } - } - - // 检查最后一段连续 - if (currentStreakCount > maxStreak) { - maxStreak = currentStreakCount - maxStreakStart = currentStreakStart - maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1] - } - - // 计算当前连续(是否以最后一天结束) - let finalCurrentStreak = 0 - if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) { - // 从最后一天往前数 - finalCurrentStreak = 1 - for (let i = sortedMemberDays.length - 2; i >= 0; i--) { - const currDate = new Date(sortedMemberDays[i + 1]) - const prevDate = new Date(sortedMemberDays[i]) - const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) - if (diffDays === 1) { - finalCurrentStreak++ - } else { - break - } - } - } - - streakData.push({ - memberId, - name: data.name, - maxStreak, - maxStreakStart, - maxStreakEnd, - currentStreak: finalCurrentStreak, - }) - - loyaltyData.push({ - memberId, - name: data.name, - totalDays: totalMemberDays, - }) - } - - // 5. 排序 - const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak) - - const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays) - const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1 - const loyaltyRank = sortedLoyalty.map((item) => ({ - ...item, - percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100), - })) - - return { - streakRank, - loyaltyRank, - totalDays, - } -} diff --git a/electron/main/worker/query/advanced/behavior.ts b/electron/main/worker/query/advanced/behavior.ts deleted file mode 100644 index 4bed190..0000000 --- a/electron/main/worker/query/advanced/behavior.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 行为分析模块 - * 包含:斗图分析 - */ - -import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' - -// ==================== 斗图分析 ==================== - -/** - * 获取斗图分析数据 - * 斗图定义:至少2人参与,总共发了3张图(图片或表情),中间无文本打断 - */ -export function getMemeBattleAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - topBattles: [], - rankByCount: [], - rankByImageCount: [], - totalBattles: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - - // 排除系统消息 (type=6) - // 斗图只看图片(1)和表情(5),其他类型(如文本0, 语音2等)视为打断 - // 我们查询所有非系统消息,在内存中遍历判断 - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += ' AND msg.type != 6' - } else { - whereClause = ' WHERE msg.type != 6' - } - - const messages = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - msg.type, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - senderId: number - type: number - ts: number - platformId: string - name: string - }> - - const battles: Array<{ - startTime: number - endTime: number - msgs: Array<{ senderId: number; name: string; platformId: string }> - }> = [] - - let currentChain: Array<{ senderId: number; name: string; platformId: string; ts: number }> = [] - - // 辅助函数:处理当前链 - const processChain = () => { - if (currentChain.length >= 3) { - const senders = new Set(currentChain.map((m) => m.senderId)) - if (senders.size >= 2) { - // 满足条件:至少3张图,至少2人 - battles.push({ - startTime: currentChain[0].ts, - endTime: currentChain[currentChain.length - 1].ts, - msgs: currentChain.map(({ senderId, name, platformId }) => ({ senderId, name, platformId })), - }) - } - } - currentChain = [] - } - - for (const msg of messages) { - // 1=图片, 5=表情 - if (msg.type === 1 || msg.type === 5) { - currentChain.push({ - senderId: msg.senderId, - name: msg.name, - platformId: msg.platformId, - ts: msg.ts, - }) - } else { - // 其他类型消息(文本、语音等)打断斗图 - processChain() - } - } - // 处理最后一条链 - processChain() - - if (battles.length === 0) return emptyResult - - // 1. 史诗级斗图榜(前30) - const topBattles = battles - .map((battle) => ({ - startTime: battle.startTime, - endTime: battle.endTime, - totalImages: battle.msgs.length, - participantCount: new Set(battle.msgs.map((m) => m.senderId)).size, - participants: Object.values( - battle.msgs.reduce( - (acc, curr) => { - if (!acc[curr.senderId]) { - acc[curr.senderId] = { memberId: curr.senderId, name: curr.name, imageCount: 0 } - } - acc[curr.senderId].imageCount++ - return acc - }, - {} as Record - ) - ).sort((a, b) => b.imageCount - a.imageCount), - })) - .sort((a, b) => b.totalImages - a.totalImages) - .slice(0, 30) - - // 2. 统计达人榜 - const memberStats = new Map< - number, - { - memberId: number - platformId: string - name: string - battleCount: number // 参与场次 - imageCount: number // 发图总数 - } - >() - - for (const battle of battles) { - const participantsInBattle = new Set() - - for (const msg of battle.msgs) { - if (!memberStats.has(msg.senderId)) { - memberStats.set(msg.senderId, { - memberId: msg.senderId, - platformId: msg.platformId, - name: msg.name, - battleCount: 0, - imageCount: 0, - }) - } - const stats = memberStats.get(msg.senderId)! - stats.imageCount++ - participantsInBattle.add(msg.senderId) - } - - // 参与场次+1 - for (const memberId of participantsInBattle) { - const stats = memberStats.get(memberId)! - stats.battleCount++ - } - } - - const allStats = Array.from(memberStats.values()) - - // 按参与场次排名 - const rankByCount = [...allStats] - .sort((a, b) => b.battleCount - a.battleCount) - .map((item) => ({ - memberId: item.memberId, - platformId: item.platformId, - name: item.name, - count: item.battleCount, - percentage: battles.length > 0 ? Math.round((item.battleCount / battles.length) * 10000) / 100 : 0, - })) - - // 按图片总数排名 - const totalBattleImages = battles.reduce((sum, b) => sum + b.msgs.length, 0) - const rankByImageCount = [...allStats] - .sort((a, b) => b.imageCount - a.imageCount) - .map((item) => ({ - memberId: item.memberId, - platformId: item.platformId, - name: item.name, - count: item.imageCount, - percentage: totalBattleImages > 0 ? Math.round((item.imageCount / totalBattleImages) * 10000) / 100 : 0, - })) - - return { - topBattles, - rankByCount, - rankByImageCount, - totalBattles: battles.length, - } -} diff --git a/electron/main/worker/query/advanced/index.ts b/electron/main/worker/query/advanced/index.ts index d3cb961..fde9f8a 100644 --- a/electron/main/worker/query/advanced/index.ts +++ b/electron/main/worker/query/advanced/index.ts @@ -3,14 +3,8 @@ * 统一导出所有分析函数 */ -// 复读 + 口头禅分析 -export { getRepeatAnalysis, getCatchphraseAnalysis } from './repeat' - -// 活跃度分析:夜猫、龙王、潜水、打卡 -export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheckInAnalysis } from './activity' - -// 行为分析:斗图 -export { getMemeBattleAnalysis } from './behavior' +// 口头禅分析 +export { getCatchphraseAnalysis } from './repeat' // 社交分析:@ 互动、含笑量、小团体 export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social' diff --git a/electron/main/worker/query/advanced/repeat.ts b/electron/main/worker/query/advanced/repeat.ts index 7eab984..d519eda 100644 --- a/electron/main/worker/query/advanced/repeat.ts +++ b/electron/main/worker/query/advanced/repeat.ts @@ -1,266 +1,9 @@ /** - * 复读分析 + 口头禅分析模块 + * 口头禅分析模块 */ import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' -// ==================== 复读分析 ==================== - -/** - * 获取复读分析数据 - */ -export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - originators: [], - initiators: [], - breakers: [], - originatorRates: [], - initiatorRates: [], - breakerRates: [], - chainLengthDistribution: [], - hotContents: [], - avgChainLength: 0, - totalRepeatChains: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.content, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC, msg.id ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - content: string - ts: number - platformId: string - name: string - }> - - const originatorCount = new Map() - const initiatorCount = new Map() - const breakerCount = new Map() - const memberMessageCount = new Map() - const memberInfo = new Map() - const chainLengthCount = new Map() - const contentStats = new Map< - string, - { count: number; maxChainLength: number; originatorId: number; lastTs: number; firstMessageId: number } - >() - - let currentContent: string | null = null - let repeatChain: Array<{ id: number; senderId: number; content: string; ts: number }> = [] - let totalRepeatChains = 0 - let totalChainLength = 0 - - const fastestRepeaterStats = new Map() - - const processRepeatChain = ( - chain: Array<{ id: number; senderId: number; content: string; ts: number }>, - breakerId?: number - ) => { - if (chain.length < 3) return - - totalRepeatChains++ - const chainLength = chain.length - totalChainLength += chainLength - - const originatorId = chain[0].senderId - originatorCount.set(originatorId, (originatorCount.get(originatorId) || 0) + 1) - - const initiatorId = chain[1].senderId - initiatorCount.set(initiatorId, (initiatorCount.get(initiatorId) || 0) + 1) - - if (breakerId !== undefined) { - breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1) - } - - chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1) - - const content = chain[0].content - const chainTs = chain[0].ts - const firstMsgId = chain[0].id - const existing = contentStats.get(content) - if (existing) { - existing.count++ - existing.lastTs = Math.max(existing.lastTs, chainTs) - if (chainLength > existing.maxChainLength) { - existing.maxChainLength = chainLength - existing.originatorId = originatorId - existing.firstMessageId = firstMsgId - } - } else { - contentStats.set(content, { - count: 1, - maxChainLength: chainLength, - originatorId, - lastTs: chainTs, - firstMessageId: firstMsgId, - }) - } - - // 计算反应时间 (Fastest Follower) - // 从第二个消息开始,计算与前一条消息的时间差 - for (let i = 1; i < chain.length; i++) { - const currentMsg = chain[i] - const prevMsg = chain[i - 1] - const diff = (currentMsg.ts - prevMsg.ts) * 1000 // 毫秒 - - // 只统计 20 秒内的复读,排除间隔过久的"伪复读" - if (diff <= 20 * 1000) { - if (!fastestRepeaterStats.has(currentMsg.senderId)) { - fastestRepeaterStats.set(currentMsg.senderId, { totalDiff: 0, count: 0 }) - } - const stats = fastestRepeaterStats.get(currentMsg.senderId)! - stats.totalDiff += diff - stats.count++ - } - } - } - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1) - - const content = msg.content.trim() - - if (content === currentContent) { - const lastSender = repeatChain[repeatChain.length - 1]?.senderId - if (lastSender !== msg.senderId) { - repeatChain.push({ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }) - } - } else { - processRepeatChain(repeatChain, msg.senderId) - - currentContent = content - repeatChain = [{ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }] - } - } - - processRepeatChain(repeatChain) - - const buildRankList = (countMap: Map, total: number): any[] => { - const items: any[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - if (info) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, - }) - } - } - return items.sort((a, b) => b.count - a.count) - } - - const buildRateList = (countMap: Map): any[] => { - const items: any[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - const totalMessages = memberMessageCount.get(memberId) || 0 - if (info && totalMessages > 0) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - totalMessages, - rate: Math.round((count / totalMessages) * 10000) / 100, - }) - } - } - return items.sort((a, b) => b.rate - a.rate) - } - - const buildFastestList = (): any[] => { - const items: any[] = [] - for (const [memberId, stats] of fastestRepeaterStats.entries()) { - // 过滤掉偶尔复读的人,至少参与5次复读才统计,避免数据偏差 - if (stats.count < 5) continue - - const info = memberInfo.get(memberId) - if (info) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTimeDiff: Math.round(stats.totalDiff / stats.count), - }) - } - } - return items.sort((a, b) => a.avgTimeDiff - b.avgTimeDiff) // 越快越好 - } - - const chainLengthDistribution: any[] = [] - for (const [length, count] of chainLengthCount.entries()) { - chainLengthDistribution.push({ length, count }) - } - chainLengthDistribution.sort((a, b) => a.length - b.length) - - const hotContents: any[] = [] - for (const [content, stats] of contentStats.entries()) { - const originatorInfo = memberInfo.get(stats.originatorId) - hotContents.push({ - content, - count: stats.count, - maxChainLength: stats.maxChainLength, - originatorName: originatorInfo?.name || '未知', - lastTs: stats.lastTs, - firstMessageId: stats.firstMessageId, - }) - } - hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength) - const top50HotContents = hotContents.slice(0, 100) - - return { - originators: buildRankList(originatorCount, totalRepeatChains), - initiators: buildRankList(initiatorCount, totalRepeatChains), - breakers: buildRankList(breakerCount, totalRepeatChains), - fastestRepeaters: buildFastestList(), - originatorRates: buildRateList(originatorCount), - initiatorRates: buildRateList(initiatorCount), - breakerRates: buildRateList(breakerCount), - chainLengthDistribution, - hotContents: top50HotContents, - avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0, - totalRepeatChains, - } -} - -// ==================== 口头禅分析 ==================== - /** * 获取口头禅分析数据 */ diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 9d57a90..8677545 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -31,13 +31,7 @@ export type { MembersPaginationParams, MembersPaginatedResult } from './basic' // 高级分析 export { - getRepeatAnalysis, getCatchphraseAnalysis, - getNightOwlAnalysis, - getDragonKingAnalysis, - getDivingAnalysis, - getCheckInAnalysis, - getMemeBattleAnalysis, getMentionAnalysis, getMentionGraph, getLaughAnalysis, @@ -62,7 +56,7 @@ export { export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './messages' // SQL 实验室 -export { executeRawSQL, getSchema } from './sql' +export { executeRawSQL, getSchema, executePluginQuery } from './sql' export type { SQLResult, TableSchema } from './sql' // 会话索引 diff --git a/electron/main/worker/query/sql.ts b/electron/main/worker/query/sql.ts index ef1a501..8aae29d 100644 --- a/electron/main/worker/query/sql.ts +++ b/electron/main/worker/query/sql.ts @@ -77,6 +77,28 @@ export function getSchema(sessionId: string): TableSchema[] { return schema } +/** + * 插件专用:参数化只读 SQL 查询 + * - 强制 stmt.readonly 检查(better-sqlite3 原生特性) + * - 参数化执行(防注入 + 预编译缓存) + */ +export function executePluginQuery>(sessionId: string, sql: string, params: any[] = []): T[] { + const db = openDatabase(sessionId) + if (!db) { + throw new Error('数据库不存在') + } + + const stmt = db.prepare(sql.trim()) + + // 安全防线:强制只读检查 + if (!stmt.readonly) { + throw new Error('Plugin Security Violation: Only READ-ONLY statements are allowed.') + } + + // 参数化执行 + return stmt.all(...params) as T[] +} + /** * 检查 SQL 是否包含 LIMIT 子句 */ diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 794dcf3..b03c0a4 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -122,7 +122,7 @@ export function initWorker(): void { /** * 发送消息到 Worker 并等待响应 */ -function sendToWorker(type: string, payload: any, timeoutMs: number = 30000): Promise { +function sendToWorker(type: string, payload: any, timeoutMs: number = 60000): Promise { return new Promise((resolve, reject) => { if (!worker) { try { @@ -228,6 +228,28 @@ export async function query(type: string, payload: any): Promise { return sendToWorker(type, payload) } +// ==================== 插件系统 API ==================== + +/** + * 插件参数化只读 SQL 查询 + * 超时设为 120s,因为多个 pluginQuery 可能在 Worker 队列中排队等待 + */ +export async function pluginQuery>( + sessionId: string, + sql: string, + params: any[] = [] +): Promise { + return sendToWorker('pluginQuery', { sessionId, sql, params }, 120000) +} + +/** + * 插件计算卸载(纯函数在 Worker 中执行) + * 超时设为 120s,因为计算密集型任务 + 排队等待可能较长 + */ +export async function pluginCompute(fnString: string, input: any): Promise { + return sendToWorker('pluginCompute', { fnString, input }, 120000) +} + // ==================== 导出的异步 API ==================== export async function getAvailableYears(sessionId: string): Promise { @@ -274,26 +296,10 @@ export async function getMemberNameHistory(sessionId: string, memberId: number): return sendToWorker('getMemberNameHistory', { sessionId, memberId }) } -export async function getRepeatAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getRepeatAnalysis', { sessionId, filter }) -} - export async function getCatchphraseAnalysis(sessionId: string, filter?: any): Promise { return sendToWorker('getCatchphraseAnalysis', { sessionId, filter }) } -export async function getNightOwlAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getNightOwlAnalysis', { sessionId, filter }) -} - -export async function getDragonKingAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getDragonKingAnalysis', { sessionId, filter }) -} - -export async function getDivingAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getDivingAnalysis', { sessionId, filter }) -} - export async function getMentionAnalysis(sessionId: string, filter?: any): Promise { return sendToWorker('getMentionAnalysis', { sessionId, filter }) } @@ -310,14 +316,6 @@ export async function getClusterGraph(sessionId: string, filter?: any, options?: return sendToWorker('getClusterGraph', { sessionId, filter, options }) } -export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) -} - -export async function getCheckInAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getCheckInAnalysis', { sessionId, filter }) -} - export async function getAllSessions(): Promise { return sendToWorker('getAllSessions', {}) } diff --git a/electron/preload/apis/chat.ts b/electron/preload/apis/chat.ts index 25c097f..4e167f6 100644 --- a/electron/preload/apis/chat.ts +++ b/electron/preload/apis/chat.ts @@ -10,15 +10,9 @@ import type { DailyActivity, WeekdayActivity, MonthlyActivity, - RepeatAnalysis, CatchphraseAnalysis, - NightOwlAnalysis, - DragonKingAnalysis, - DivingAnalysis, MentionAnalysis, LaughAnalysis, - CheckInAnalysis, - MemeBattleAnalysis, MemberWithStats, ClusterGraphData, ClusterGraphOptions, @@ -248,13 +242,6 @@ export const chatApi = { } }, - /** - * 获取复读分析数据 - */ - getRepeatAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getRepeatAnalysis', sessionId, filter) - }, - /** * 获取口头禅分析数据 */ @@ -265,33 +252,6 @@ export const chatApi = { return ipcRenderer.invoke('chat:getCatchphraseAnalysis', sessionId, filter) }, - /** - * 获取夜猫分析数据 - */ - getNightOwlAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getNightOwlAnalysis', sessionId, filter) - }, - - /** - * 获取龙王分析数据 - */ - getDragonKingAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getDragonKingAnalysis', sessionId, filter) - }, - - /** - * 获取潜水分析数据 - */ - getDivingAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getDivingAnalysis', sessionId, filter) - }, - /** * 获取 @ 互动分析数据 */ @@ -335,23 +295,6 @@ export const chatApi = { return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords) }, - /** - * 获取斗图分析数据 - */ - getMemeBattleAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter) - }, - - /** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ - getCheckInAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getCheckInAnalysis', sessionId, filter) - }, - // ==================== 成员管理 ==================== /** @@ -399,6 +342,22 @@ export const chatApi = { return ipcRenderer.invoke('chat:updateSessionOwnerId', sessionId, ownerId) }, + // ==================== 插件系统 ==================== + + /** + * 插件参数化只读 SQL 查询 + */ + pluginQuery: >(sessionId: string, sql: string, params: any[] = []): Promise => { + return ipcRenderer.invoke('chat:pluginQuery', sessionId, sql, params) + }, + + /** + * 插件计算卸载(纯函数在 Worker 中执行) + */ + pluginCompute: (fnString: string, input: any): Promise => { + return ipcRenderer.invoke('chat:pluginCompute', fnString, input) + }, + // ==================== SQL 实验室 ==================== /** diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 2ec818f..693e4c7 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -7,15 +7,9 @@ import type { DailyActivity, WeekdayActivity, MonthlyActivity, - RepeatAnalysis, CatchphraseAnalysis, - NightOwlAnalysis, - DragonKingAnalysis, - DivingAnalysis, MentionAnalysis, LaughAnalysis, - MemeBattleAnalysis, - CheckInAnalysis, MemberWithStats, ClusterGraphData, ClusterGraphOptions, @@ -141,17 +135,11 @@ interface ChatApi { getDbDirectory: () => Promise getSupportedFormats: () => Promise> onImportProgress: (callback: (progress: ImportProgress) => void) => () => void - getRepeatAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getNightOwlAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getDragonKingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise getMentionGraph: (sessionId: string, filter?: TimeFilter) => Promise getClusterGraph: (sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions) => Promise getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise - getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise // 成员管理 getMembers: (sessionId: string) => Promise getMembersPaginated: ( @@ -166,6 +154,9 @@ interface ChatApi { }> updateMemberAliases: (sessionId: string, memberId: number, aliases: string[]) => Promise deleteMember: (sessionId: string, memberId: number) => Promise + // 插件系统 + pluginQuery: >(sessionId: string, sql: string, params?: any[]) => Promise + pluginCompute: (fnString: string, input: any) => Promise // SQL 实验室 getSchema: (sessionId: string) => Promise executeSQL: (sessionId: string, sql: string) => Promise diff --git a/eslint.config.mjs b/eslint.config.mjs index 6b21e14..1ce32c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,8 @@ export default defineConfigWithVueTs( // Vue 规则放宽 'vue/require-default-prop': 'off', 'vue/multi-word-component-names': 'off', + // 项目中有受控的 HTML 渲染场景(如 Markdown/高亮结果),统一关闭该告警。 + 'vue/no-v-html': 'off', // TypeScript 规则放宽(项目约定) '@typescript-eslint/no-unused-vars': 'off', diff --git a/src/components/view/ClusterView.vue b/packages/chart-cluster/ClusterView.vue similarity index 81% rename from src/components/view/ClusterView.vue rename to packages/chart-cluster/ClusterView.vue index cc44d8c..e55b8c1 100644 --- a/src/components/view/ClusterView.vue +++ b/packages/chart-cluster/ClusterView.vue @@ -1,33 +1,36 @@ @@ -296,34 +269,23 @@ watch(
-
- -
+
+
- -
+
-
- -
- {{ item.name }} -
- +
{{ item.name }}
-
{{ item.count.toLocaleString() }} @@ -342,14 +304,12 @@ watch(
-
-
@@ -359,14 +319,12 @@ watch(
-
-
@@ -377,9 +335,8 @@ watch(
- +
-