feat: 新增聊天记录查看器

This commit is contained in:
digua
2025-12-15 10:12:52 +08:00
parent 84af222b25
commit 266f8644e1
20 changed files with 1385 additions and 48 deletions
+134
View File
@@ -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 }
}
}
)
}
+2
View File
@@ -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')
+4
View File
@@ -37,6 +37,8 @@ import {
getMessageContext,
getRecentMessages,
getConversationBetween,
getMessagesBefore,
getMessagesAfter,
// 成员管理
getMembers,
updateMemberAliases,
@@ -105,6 +107,8 @@ const syncHandlers: Record<string, (payload: any) => 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),
@@ -70,17 +70,17 @@ export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any {
const chainLengthCount = new Map<number, number>()
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<number, { totalDiff: number; count: number }>()
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)
+12 -2
View File
@@ -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'
@@ -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,
+28
View File
@@ -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 {
+63 -4
View File
@@ -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<SearchMessageResult[]> => {
return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageId, contextSize)
getMessageContext: (
sessionId: string,
messageIds: number | number[],
contextSize?: number
): Promise<SearchMessageResult[]> => {
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)
},
/**