diff --git a/electron/main/ipc/messages.ts b/electron/main/ipc/messages.ts new file mode 100644 index 0000000..16ff2ce --- /dev/null +++ b/electron/main/ipc/messages.ts @@ -0,0 +1,134 @@ +/** + * 聊天记录查询 IPC 处理器 + * 提供通用的消息查询功能:搜索、筛选、上下文、无限滚动等 + */ + +import { ipcMain } from 'electron' +import type { IpcContext } from './types' +import * as worker from '../worker/workerManager' + +export function registerMessagesHandlers({ win }: IpcContext): void { + console.log('[IPC] Registering Messages handlers...') + + /** + * 关键词搜索消息 + */ + ipcMain.handle( + 'ai:searchMessages', + async ( + _, + sessionId: string, + keywords: string[], + filter?: { startTs?: number; endTs?: number }, + limit?: number, + offset?: number, + senderId?: number + ) => { + try { + return await worker.searchMessages(sessionId, keywords, filter, limit, offset, senderId) + } catch (error) { + console.error('搜索消息失败:', error) + return { messages: [], total: 0 } + } + } + ) + + /** + * 获取消息上下文 + */ + ipcMain.handle( + 'ai:getMessageContext', + async (_, sessionId: string, messageIds: number | number[], contextSize?: number) => { + try { + return await worker.getMessageContext(sessionId, messageIds, contextSize) + } catch (error) { + console.error('获取消息上下文失败:', error) + return [] + } + } + ) + + /** + * 获取最近消息 + */ + ipcMain.handle( + 'ai:getRecentMessages', + async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => { + try { + return await worker.getRecentMessages(sessionId, filter, limit) + } catch (error) { + console.error('获取最近消息失败:', error) + return { messages: [], total: 0 } + } + } + ) + + /** + * 获取两人之间的对话 + */ + ipcMain.handle( + 'ai:getConversationBetween', + async ( + _, + sessionId: string, + memberId1: number, + memberId2: number, + filter?: { startTs?: number; endTs?: number }, + limit?: number + ) => { + try { + return await worker.getConversationBetween(sessionId, memberId1, memberId2, filter, limit) + } catch (error) { + console.error('获取对话失败:', error) + return { messages: [], total: 0, member1Name: '', member2Name: '' } + } + } + ) + + /** + * 获取指定消息之前的 N 条(用于向上无限滚动) + */ + ipcMain.handle( + 'ai:getMessagesBefore', + async ( + _, + sessionId: string, + beforeId: number, + limit?: number, + filter?: { startTs?: number; endTs?: number }, + senderId?: number, + keywords?: string[] + ) => { + try { + return await worker.getMessagesBefore(sessionId, beforeId, limit, filter, senderId, keywords) + } catch (error) { + console.error('获取之前消息失败:', error) + return { messages: [], hasMore: false } + } + } + ) + + /** + * 获取指定消息之后的 N 条(用于向下无限滚动) + */ + ipcMain.handle( + 'ai:getMessagesAfter', + async ( + _, + sessionId: string, + afterId: number, + limit?: number, + filter?: { startTs?: number; endTs?: number }, + senderId?: number, + keywords?: string[] + ) => { + try { + return await worker.getMessagesAfter(sessionId, afterId, limit, filter, senderId, keywords) + } catch (error) { + console.error('获取之后消息失败:', error) + return { messages: [], hasMore: false } + } + } + ) +} + diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index ddce522..fd39713 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -10,6 +10,7 @@ import { registerWindowHandlers } from './ipc/window' import { registerChatHandlers } from './ipc/chat' import { registerMergeHandlers, initMergeModule } from './ipc/merge' import { registerAIHandlers } from './ipc/ai' +import { registerMessagesHandlers } from './ipc/messages' import { registerCacheHandlers } from './ipc/cache' // 导入 Worker 模块(用于异步分析查询和流式导入) import * as worker from './worker/workerManager' @@ -39,6 +40,7 @@ const mainIpcMain = (win: BrowserWindow) => { registerChatHandlers(context) registerMergeHandlers(context) registerAIHandlers(context) + registerMessagesHandlers(context) registerCacheHandlers(context) console.log('[IpcMain] All IPC handlers registered successfully') diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index f55dff2..4662043 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -37,6 +37,8 @@ import { getMessageContext, getRecentMessages, getConversationBetween, + getMessagesBefore, + getMessagesAfter, // 成员管理 getMembers, updateMemberAliases, @@ -105,6 +107,8 @@ const syncHandlers: Record any> = { getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize), getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit), getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit), + getMessagesBefore: (p) => getMessagesBefore(p.sessionId, p.beforeId, p.limit, p.filter, p.senderId, p.keywords), + getMessagesAfter: (p) => getMessagesAfter(p.sessionId, p.afterId, p.limit, p.filter, p.senderId, p.keywords), // SQL 实验室 executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), diff --git a/electron/main/worker/query/advanced/repeat.ts b/electron/main/worker/query/advanced/repeat.ts index ad6629c..c891b43 100644 --- a/electron/main/worker/query/advanced/repeat.ts +++ b/electron/main/worker/query/advanced/repeat.ts @@ -70,17 +70,17 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { const chainLengthCount = new Map() const contentStats = new Map< string, - { count: number; maxChainLength: number; originatorId: number; lastTs: number } + { count: number; maxChainLength: number; originatorId: number; lastTs: number; firstMessageId: number } >() let currentContent: string | null = null - let repeatChain: Array<{ senderId: number; content: string; ts: number }> = [] + 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<{ senderId: number; content: string; ts: number }>, breakerId?: number) => { + const processRepeatChain = (chain: Array<{ id: number; senderId: number; content: string; ts: number }>, breakerId?: number) => { if (chain.length < 3) return totalRepeatChains++ @@ -101,6 +101,7 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { const content = chain[0].content const chainTs = chain[0].ts + const firstMsgId = chain[0].id const existing = contentStats.get(content) if (existing) { existing.count++ @@ -108,9 +109,10 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { if (chainLength > existing.maxChainLength) { existing.maxChainLength = chainLength existing.originatorId = originatorId + existing.firstMessageId = firstMsgId } } else { - contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs }) + contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs, firstMessageId: firstMsgId }) } // 计算反应时间 (Fastest Follower) @@ -144,13 +146,13 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { if (content === currentContent) { const lastSender = repeatChain[repeatChain.length - 1]?.senderId if (lastSender !== msg.senderId) { - repeatChain.push({ senderId: msg.senderId, content, ts: msg.ts }) + repeatChain.push({ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }) } } else { processRepeatChain(repeatChain, msg.senderId) currentContent = content - repeatChain = [{ senderId: msg.senderId, content, ts: msg.ts }] + repeatChain = [{ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }] } } @@ -227,6 +229,7 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { maxChainLength: stats.maxChainLength, originatorName: originatorInfo?.name || '未知', lastTs: stats.lastTs, + firstMessageId: stats.firstMessageId, }) } hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength) diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index 582806f..ab99578 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -36,8 +36,18 @@ export { getLaughAnalysis, } from './advanced' -// AI 查询 -export { searchMessages, getMessageContext, getRecentMessages, getConversationBetween } from './ai' +// 聊天记录查询 +export { + searchMessages, + getMessageContext, + getRecentMessages, + getConversationBetween, + getMessagesBefore, + getMessagesAfter, +} from './messages' + +// 聊天记录查询类型 +export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './messages' // SQL 实验室 export { executeRawSQL, getSchema } from './sql' diff --git a/electron/main/worker/query/ai.ts b/electron/main/worker/query/messages.ts similarity index 54% rename from electron/main/worker/query/ai.ts rename to electron/main/worker/query/messages.ts index 111542d..95a7dad 100644 --- a/electron/main/worker/query/ai.ts +++ b/electron/main/worker/query/messages.ts @@ -1,16 +1,17 @@ /** - * AI 查询模块 - * 提供关键词搜索和最近消息获取功能(在 Worker 线程中执行) + * 聊天记录查询模块 + * 提供通用的消息查询功能:搜索、筛选、上下文、无限滚动等 + * 在 Worker 线程中执行 */ import { openDatabase, buildTimeFilter, type TimeFilter } from '../core' -// ==================== 消息搜索 ==================== +// ==================== 类型定义 ==================== /** - * 搜索消息结果类型 + * 消息查询结果类型 */ -export interface SearchMessageResult { +export interface MessageResult { id: number senderName: string senderPlatformId: string @@ -20,7 +21,70 @@ export interface SearchMessageResult { } /** - * 获取最近的消息(用于概览性问题) + * 分页消息结果 + */ +export interface PaginatedMessages { + messages: MessageResult[] + hasMore: boolean +} + +/** + * 带总数的消息结果 + */ +export interface MessagesWithTotal { + messages: MessageResult[] + total: number +} + +// ==================== 工具函数 ==================== + +/** + * 将数据库行转换为可序列化的 MessageResult + * 处理 BigInt 等类型,确保 IPC 传输安全 + */ +function sanitizeMessageRow(row: MessageResult): MessageResult { + return { + id: Number(row.id), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + } +} + +/** + * 构建通用的发送者筛选条件 + */ +function buildSenderCondition(senderId?: number): { condition: string; params: number[] } { + if (senderId === undefined) { + return { condition: '', params: [] } + } + return { condition: 'AND msg.sender_id = ?', params: [senderId] } +} + +/** + * 构建关键词筛选条件(OR 逻辑) + */ +function buildKeywordCondition(keywords?: string[]): { condition: string; params: string[] } { + if (!keywords || keywords.length === 0) { + return { condition: '', params: [] } + } + const condition = `AND (${keywords.map(() => `msg.content LIKE ?`).join(' OR ')})` + const params = keywords.map((k) => `%${k}%`) + return { condition, params } +} + +// 排除系统消息的通用过滤条件 +const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'" + +// 只获取文本消息的过滤条件 +const TEXT_ONLY_FILTER = 'AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != \'\'' + +// ==================== 查询函数 ==================== + +/** + * 获取最近的消息 * @param sessionId 会话 ID * @param filter 时间过滤器 * @param limit 返回数量限制 @@ -29,7 +93,7 @@ export function getRecentMessages( sessionId: string, filter?: TimeFilter, limit: number = 100 -): { messages: SearchMessageResult[]; total: number } { +): MessagesWithTotal { const db = openDatabase(sessionId) if (!db) return { messages: [], total: 0 } @@ -37,9 +101,6 @@ export function getRecentMessages( const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - // 排除系统消息,只获取文本消息(type=0) - const systemFilter = "AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != ''" - // 查询总数 const countSql = ` SELECT COUNT(*) as total @@ -47,7 +108,8 @@ export function getRecentMessages( JOIN member m ON msg.sender_id = m.id WHERE 1=1 ${timeCondition} - ${systemFilter} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} ` const totalRow = db.prepare(countSql).get(...timeParams) as { total: number } const total = totalRow?.total || 0 @@ -65,15 +127,19 @@ export function getRecentMessages( JOIN member m ON msg.sender_id = m.id WHERE 1=1 ${timeCondition} - ${systemFilter} + ${SYSTEM_FILTER} + ${TEXT_ONLY_FILTER} ORDER BY msg.ts DESC LIMIT ? ` - const rows = db.prepare(sql).all(...timeParams, limit) as SearchMessageResult[] + const rows = db.prepare(sql).all(...timeParams, limit) as MessageResult[] // 返回时按时间正序排列(便于阅读) - return { messages: rows.reverse(), total } + return { + messages: rows.map(sanitizeMessageRow).reverse(), + total, + } } /** @@ -83,7 +149,7 @@ export function getRecentMessages( * @param filter 时间过滤器 * @param limit 返回数量限制 * @param offset 偏移量(分页) - * @param senderId 可选的发送者成员 ID,用于筛选特定成员的消息 + * @param senderId 可选的发送者成员 ID */ export function searchMessages( sessionId: string, @@ -92,7 +158,7 @@ export function searchMessages( limit: number = 20, offset: number = 0, senderId?: number -): { messages: SearchMessageResult[]; total: number } { +): MessagesWithTotal { const db = openDatabase(sessionId) if (!db) return { messages: [], total: 0 } @@ -108,16 +174,8 @@ export function searchMessages( const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - // 排除系统消息 - const systemFilter = "AND COALESCE(m.account_name, '') != '系统消息'" - // 构建发送者筛选条件 - let senderCondition = '' - const senderParams: number[] = [] - if (senderId !== undefined) { - senderCondition = 'AND msg.sender_id = ?' - senderParams.push(senderId) - } + const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) // 查询总数 const countSql = ` @@ -126,7 +184,7 @@ export function searchMessages( JOIN member m ON msg.sender_id = m.id WHERE ${keywordCondition} ${timeCondition} - ${systemFilter} + ${SYSTEM_FILTER} ${senderCondition} ` const totalRow = db.prepare(countSql).get(...keywordParams, ...timeParams, ...senderParams) as { total: number } @@ -145,15 +203,18 @@ export function searchMessages( JOIN member m ON msg.sender_id = m.id WHERE ${keywordCondition} ${timeCondition} - ${systemFilter} + ${SYSTEM_FILTER} ${senderCondition} ORDER BY msg.ts DESC LIMIT ? OFFSET ? ` - const rows = db.prepare(sql).all(...keywordParams, ...timeParams, ...senderParams, limit, offset) as SearchMessageResult[] + const rows = db.prepare(sql).all(...keywordParams, ...timeParams, ...senderParams, limit, offset) as MessageResult[] - return { messages: rows, total } + return { + messages: rows.map(sanitizeMessageRow), + total, + } } /** @@ -168,7 +229,7 @@ export function getMessageContext( sessionId: string, messageIds: number | number[], contextSize: number = 20 -): SearchMessageResult[] { +): MessageResult[] { const db = openDatabase(sessionId) if (!db) return [] @@ -225,9 +286,130 @@ export function getMessageContext( ORDER BY msg.id ASC ` - const rows = db.prepare(sql).all(...idList) as SearchMessageResult[] + const rows = db.prepare(sql).all(...idList) as MessageResult[] - return rows + return rows.map(sanitizeMessageRow) +} + +/** + * 获取指定消息之前的 N 条消息(用于向上无限滚动) + * @param sessionId 会话 ID + * @param beforeId 在此消息 ID 之前的消息 + * @param limit 返回数量限制 + * @param filter 可选的时间筛选条件 + * @param senderId 可选的发送者筛选 + * @param keywords 可选的关键词筛选 + */ +export function getMessagesBefore( + sessionId: string, + beforeId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): PaginatedMessages { + const db = openDatabase(sessionId) + if (!db) return { messages: [], hasMore: false } + + // 构建时间过滤条件 + const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + // 构建关键词条件 + const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords) + + // 构建发送者筛选条件 + const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) + + const sql = ` + SELECT + msg.id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.id < ? + ${timeCondition} + ${keywordCondition} + ${senderCondition} + ${SYSTEM_FILTER} + ORDER BY msg.id DESC + LIMIT ? + ` + + const rows = db.prepare(sql).all(beforeId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as MessageResult[] + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + // 返回时按 ID 升序排列 + return { + messages: resultRows.map(sanitizeMessageRow).reverse(), + hasMore, + } +} + +/** + * 获取指定消息之后的 N 条消息(用于向下无限滚动) + * @param sessionId 会话 ID + * @param afterId 在此消息 ID 之后的消息 + * @param limit 返回数量限制 + * @param filter 可选的时间筛选条件 + * @param senderId 可选的发送者筛选 + * @param keywords 可选的关键词筛选 + */ +export function getMessagesAfter( + sessionId: string, + afterId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): PaginatedMessages { + const db = openDatabase(sessionId) + if (!db) return { messages: [], hasMore: false } + + // 构建时间过滤条件 + const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + // 构建关键词条件 + const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords) + + // 构建发送者筛选条件 + const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) + + const sql = ` + SELECT + msg.id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content, + msg.ts as timestamp, + msg.type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.id > ? + ${timeCondition} + ${keywordCondition} + ${senderCondition} + ${SYSTEM_FILTER} + ORDER BY msg.id ASC + LIMIT ? + ` + + const rows = db.prepare(sql).all(afterId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as MessageResult[] + + const hasMore = rows.length > limit + const resultRows = hasMore ? rows.slice(0, limit) : rows + + return { + messages: resultRows.map(sanitizeMessageRow), + hasMore, + } } /** @@ -245,7 +427,7 @@ export function getConversationBetween( memberId2: number, filter?: TimeFilter, limit: number = 100 -): { messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string } { +): MessagesWithTotal & { member1Name: string; member2Name: string } { const db = openDatabase(sessionId) if (!db) return { messages: [], total: 0, member1Name: '', member2Name: '' } @@ -298,11 +480,11 @@ export function getConversationBetween( LIMIT ? ` - const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as SearchMessageResult[] + const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as MessageResult[] // 返回时按时间正序排列(便于阅读对话) return { - messages: rows.reverse(), + messages: rows.map(sanitizeMessageRow).reverse(), total, member1Name: member1.name, member2Name: member2.name, diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 0b6cbe2..0db2640 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -436,6 +436,34 @@ export async function getConversationBetween( return sendToWorker('getConversationBetween', { sessionId, memberId1, memberId2, filter, limit }) } +/** + * 获取指定消息之前的 N 条消息(用于向上无限滚动) + */ +export async function getMessagesBefore( + sessionId: string, + beforeId: number, + limit?: number, + filter?: any, + senderId?: number, + keywords?: string[] +): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { + return sendToWorker('getMessagesBefore', { sessionId, beforeId, limit, filter, senderId, keywords }) +} + +/** + * 获取指定消息之后的 N 条消息(用于向下无限滚动) + */ +export async function getMessagesAfter( + sessionId: string, + afterId: number, + limit?: number, + filter?: any, + senderId?: number, + keywords?: string[] +): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { + return sendToWorker('getMessagesAfter', { sessionId, afterId, limit, filter, senderId, keywords }) +} + // ==================== SQL 实验室 API ==================== export interface SQLResult { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c9b4ed4..0ef5833 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -445,22 +445,81 @@ interface AIMessage { const aiApi = { /** * 搜索消息(关键词搜索) + * @param senderId 可选的发送者成员 ID,用于筛选特定成员的消息 */ searchMessages: ( sessionId: string, keywords: string[], filter?: { startTs?: number; endTs?: number }, limit?: number, - offset?: number + offset?: number, + senderId?: number ): Promise<{ messages: SearchMessageResult[]; total: number }> => { - return ipcRenderer.invoke('ai:searchMessages', sessionId, keywords, filter, limit, offset) + return ipcRenderer.invoke('ai:searchMessages', sessionId, keywords, filter, limit, offset, senderId) }, /** * 获取消息上下文 + * @param messageIds 支持单个或批量消息 ID */ - getMessageContext: (sessionId: string, messageId: number, contextSize?: number): Promise => { - return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageId, contextSize) + getMessageContext: ( + sessionId: string, + messageIds: number | number[], + contextSize?: number + ): Promise => { + return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageIds, contextSize) + }, + + /** + * 获取最近消息 + */ + getRecentMessages: ( + sessionId: string, + filter?: { startTs?: number; endTs?: number }, + limit?: number + ): Promise<{ messages: SearchMessageResult[]; total: number }> => { + return ipcRenderer.invoke('ai:getRecentMessages', sessionId, filter, limit) + }, + + /** + * 获取两人之间的对话 + */ + getConversationBetween: ( + sessionId: string, + memberId1: number, + memberId2: number, + filter?: { startTs?: number; endTs?: number }, + limit?: number + ): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> => { + return ipcRenderer.invoke('ai:getConversationBetween', sessionId, memberId1, memberId2, filter, limit) + }, + + /** + * 获取指定消息之前的 N 条(用于向上无限滚动) + */ + getMessagesBefore: ( + sessionId: string, + beforeId: number, + limit?: number, + filter?: { startTs?: number; endTs?: number }, + senderId?: number, + keywords?: string[] + ): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => { + return ipcRenderer.invoke('ai:getMessagesBefore', sessionId, beforeId, limit, filter, senderId, keywords) + }, + + /** + * 获取指定消息之后的 N 条(用于向下无限滚动) + */ + getMessagesAfter: ( + sessionId: string, + afterId: number, + limit?: number, + filter?: { startTs?: number; endTs?: number }, + senderId?: number, + keywords?: string[] + ): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => { + return ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords) }, /** diff --git a/src/App.vue b/src/App.vue index 1a4ba24..7611ab8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ import { useRoute } from 'vue-router' import Sidebar from '@/components/common/Sidebar.vue' import SettingModal from '@/components/common/SettingModal.vue' import ScreenCaptureModal from '@/components/common/ScreenCaptureModal.vue' +import { ChatRecordDrawer } from '@/components/common/ChatRecord' const chatStore = useChatStore() const { isInitialized } = storeToRefs(chatStore) @@ -49,6 +50,8 @@ onMounted(async () => { :image-data="chatStore.screenCaptureImage" @update:open="(v) => (v ? null : chatStore.closeScreenCaptureModal())" /> + + diff --git a/src/components.d.ts b/src/components.d.ts index 267f5ba..248a27c 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -21,6 +21,7 @@ declare module 'vue' { UChatPrompt: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ChatPrompt.vue')['default'] UChatPromptSubmit: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ChatPromptSubmit.vue')['default'] UContextMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/ContextMenu.vue')['default'] + UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default'] UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default'] UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default'] UInputTags: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/InputTags.vue')['default'] diff --git a/src/components/analysis/quotes/HotRepeatTab.vue b/src/components/analysis/quotes/HotRepeatTab.vue index c2f0fea..efc296d 100644 --- a/src/components/analysis/quotes/HotRepeatTab.vue +++ b/src/components/analysis/quotes/HotRepeatTab.vue @@ -4,6 +4,7 @@ import type { RepeatAnalysis } from '@/types/chat' import { ListPro } from '@/components/charts' import { LoadingState, EmptyState, SectionCard } from '@/components/UI' import { formatDate, getRankBadgeClass } from '@/utils' +import { useChatStore } from '@/stores/chat' interface TimeFilter { startTs?: number @@ -15,6 +16,8 @@ const props = defineProps<{ timeFilter?: TimeFilter }>() +const chatStore = useChatStore() + // ==================== 最火复读内容 ==================== const repeatAnalysis = ref(null) const isLoading = ref(false) @@ -36,6 +39,16 @@ function truncateContent(content: string, maxLength = 30): string { return content.slice(0, maxLength) + '...' } +/** + * 查看复读内容的聊天记录上下文 + */ +function viewRepeatContext(item: { content: string; firstMessageId: number }) { + chatStore.openChatRecordDrawer({ + scrollToMessageId: item.firstMessageId, + highlightKeywords: [item.content], + }) +} + // 监听 sessionId 和 timeFilter 变化 watch( () => [props.sessionId, props.timeFilter], @@ -81,6 +94,14 @@ watch( {{ item.count }} 次 | {{ formatDate(item.lastTs) }} + diff --git a/src/components/common/ChatRecord/ActiveFilters.vue b/src/components/common/ChatRecord/ActiveFilters.vue new file mode 100644 index 0000000..4ba42cb --- /dev/null +++ b/src/components/common/ChatRecord/ActiveFilters.vue @@ -0,0 +1,103 @@ + + + + diff --git a/src/components/common/ChatRecord/ChatRecordDrawer.vue b/src/components/common/ChatRecord/ChatRecordDrawer.vue new file mode 100644 index 0000000..8bc1bb2 --- /dev/null +++ b/src/components/common/ChatRecord/ChatRecordDrawer.vue @@ -0,0 +1,144 @@ + + + + diff --git a/src/components/common/ChatRecord/FilterPanel.vue b/src/components/common/ChatRecord/FilterPanel.vue new file mode 100644 index 0000000..6613984 --- /dev/null +++ b/src/components/common/ChatRecord/FilterPanel.vue @@ -0,0 +1,195 @@ + + + + diff --git a/src/components/common/ChatRecord/MessageItem.vue b/src/components/common/ChatRecord/MessageItem.vue new file mode 100644 index 0000000..c3fcb1a --- /dev/null +++ b/src/components/common/ChatRecord/MessageItem.vue @@ -0,0 +1,60 @@ + + + + diff --git a/src/components/common/ChatRecord/MessageList.vue b/src/components/common/ChatRecord/MessageList.vue new file mode 100644 index 0000000..863b1c3 --- /dev/null +++ b/src/components/common/ChatRecord/MessageList.vue @@ -0,0 +1,272 @@ + + + + diff --git a/src/components/common/ChatRecord/index.ts b/src/components/common/ChatRecord/index.ts new file mode 100644 index 0000000..d0a5ef9 --- /dev/null +++ b/src/components/common/ChatRecord/index.ts @@ -0,0 +1,8 @@ +/** + * 聊天记录查看器组件 + * 导出主 Drawer 组件和相关类型 + */ + +export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue' +export * from './types' + diff --git a/src/components/common/ChatRecord/types.ts b/src/components/common/ChatRecord/types.ts new file mode 100644 index 0000000..342b9b0 --- /dev/null +++ b/src/components/common/ChatRecord/types.ts @@ -0,0 +1,33 @@ +/** + * 聊天记录查看器类型定义 + */ + +import type { ChatRecordQuery, ChatRecordMessage } from '@/types/chat' + +// 重新导出类型 +export type { ChatRecordQuery, ChatRecordMessage } + +/** + * 筛选表单数据 + */ +export interface FilterFormData { + /** 消息 ID */ + messageId: string + /** 成员名称 */ + memberName: string + /** 关键词(逗号分隔) */ + keywords: string + /** 开始日期 */ + startDate: string + /** 结束日期 */ + endDate: string +} + +/** + * 筛选器更新事件 + */ +export interface FilterUpdateEvent { + query: ChatRecordQuery + shouldReload: boolean +} + diff --git a/src/stores/chat.ts b/src/stores/chat.ts index e9a046c..760e442 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1,6 +1,13 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { AnalysisSession, ImportProgress, KeywordTemplate, PromptPreset, AIPromptSettings } from '@/types/chat' +import type { + AnalysisSession, + ImportProgress, + KeywordTemplate, + PromptPreset, + AIPromptSettings, + ChatRecordQuery, +} from '@/types/chat' import { BUILTIN_PRESETS, DEFAULT_GROUP_PRESET_ID, @@ -257,6 +264,30 @@ export const useChatStore = defineStore( }, 300) } + // ==================== 聊天记录查看器 Drawer ==================== + const showChatRecordDrawer = ref(false) + const chatRecordQuery = ref(null) + + /** + * 打开聊天记录查看器 + * @param query 查询参数,支持组合查询 + */ + function openChatRecordDrawer(query: ChatRecordQuery) { + chatRecordQuery.value = query + showChatRecordDrawer.value = true + } + + /** + * 关闭聊天记录查看器 + */ + function closeChatRecordDrawer() { + showChatRecordDrawer.value = false + // 延迟清除查询参数,避免关闭动画时内容闪烁 + setTimeout(() => { + chatRecordQuery.value = null + }, 300) + } + // AI 配置更新计数器(用于触发其他组件刷新) const aiConfigVersion = ref(0) @@ -483,6 +514,8 @@ export const useChatStore = defineStore( showSettingModal, showScreenCaptureModal, screenCaptureImage, + showChatRecordDrawer, + chatRecordQuery, aiConfigVersion, aiGlobalSettings, customKeywordTemplates, @@ -508,6 +541,8 @@ export const useChatStore = defineStore( toggleSidebar, openScreenCaptureModal, closeScreenCaptureModal, + openChatRecordDrawer, + closeChatRecordDrawer, notifyAIConfigChanged, updateAIGlobalSettings, addCustomKeywordTemplate, diff --git a/src/types/chat.ts b/src/types/chat.ts index 11ea7b2..b95766b 100644 --- a/src/types/chat.ts +++ b/src/types/chat.ts @@ -471,6 +471,7 @@ export interface HotRepeatContent { maxChainLength: number // 最长复读链长度 originatorName: string // 最长链的原创者名称 lastTs: number // 最近一次发生的时间戳(秒) + firstMessageId: number // 最长链的第一条消息 ID(用于跳转查看上下文) } /** @@ -899,3 +900,42 @@ export interface MergeResult { sessionId?: string // 如果选择了分析,返回会话ID error?: string } + +// ==================== 聊天记录查看器类型 ==================== + +/** + * 聊天记录查看器查询参数 + * 支持组合查询:多个条件可同时生效 + */ +export interface ChatRecordQuery { + /** 定位到指定消息(初始加载时以此消息为中心) */ + scrollToMessageId?: number + + /** 成员筛选:只显示该成员的消息 */ + memberId?: number + /** 成员名称(用于显示) */ + memberName?: string + + /** 时间范围筛选:开始时间戳(秒) */ + startTs?: number + /** 时间范围筛选:结束时间戳(秒) */ + endTs?: number + + /** 关键词搜索(OR 逻辑) */ + keywords?: string[] + + /** 高亮关键词(用于 UI 高亮显示) */ + highlightKeywords?: string[] +} + +/** + * 聊天记录查看器中的消息项 + */ +export interface ChatRecordMessage { + id: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number +}