mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-19 12:59:09 +08:00
feat: 新增聊天记录查看器
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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())"
|
||||
/>
|
||||
<!-- 全局聊天记录查看器 -->
|
||||
<ChatRecordDrawer />
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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<RepeatAnalysis | null>(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(
|
||||
<span>{{ item.count }} 次</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span>{{ formatDate(item.lastTs) }}</span>
|
||||
<UButton
|
||||
icon="i-heroicons-chat-bubble-left-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
title="查看聊天记录"
|
||||
@click.stop="viewRepeatContext(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 当前激活的筛选条件显示
|
||||
* 以标签形式展示,支持单个删除和全部清除
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRecordQuery } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前查询条件 */
|
||||
query: ChatRecordQuery
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 移除单个筛选条件 */
|
||||
(e: 'remove', key: keyof ChatRecordQuery): void
|
||||
/** 清除所有筛选条件 */
|
||||
(e: 'clear-all'): void
|
||||
}>()
|
||||
|
||||
// 格式化时间
|
||||
function formatDate(ts?: number): string {
|
||||
if (!ts) return ''
|
||||
return dayjs.unix(ts).format('MM-DD')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-gray-200 px-4 py-2 dark:border-gray-800">
|
||||
<span class="text-xs text-gray-500">当前筛选:</span>
|
||||
|
||||
<!-- 定位消息 -->
|
||||
<span
|
||||
v-if="query.scrollToMessageId"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-hashtag" class="h-3 w-3" />
|
||||
消息 #{{ query.scrollToMessageId }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"
|
||||
@click="emit('remove', 'scrollToMessageId')"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- 成员筛选 -->
|
||||
<span
|
||||
v-if="query.memberId || query.memberName"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-user" class="h-3 w-3" />
|
||||
{{ query.memberName || `ID: ${query.memberId}` }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-green-900 dark:hover:text-green-200"
|
||||
@click="emit('remove', 'memberId')"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<span
|
||||
v-if="query.startTs || query.endTs"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-orange-100 px-2 py-0.5 text-xs text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-calendar" class="h-3 w-3" />
|
||||
{{ formatDate(query.startTs) || '开始' }} ~ {{ formatDate(query.endTs) || '现在' }}
|
||||
<button
|
||||
class="ml-0.5 hover:text-orange-900 dark:hover:text-orange-200"
|
||||
@click="(emit('remove', 'startTs'), emit('remove', 'endTs'))"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<!-- 关键词 -->
|
||||
<span
|
||||
v-for="kw in query.keywords"
|
||||
:key="kw"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs text-violet-700 dark:bg-violet-900/30 dark:text-violet-400"
|
||||
>
|
||||
<UIcon name="i-heroicons-magnifying-glass" class="h-3 w-3" />
|
||||
{{ kw }}
|
||||
</span>
|
||||
<button
|
||||
v-if="query.keywords?.length"
|
||||
class="text-xs text-gray-400 hover:text-gray-600"
|
||||
@click="emit('remove', 'keywords')"
|
||||
>
|
||||
清除关键词
|
||||
</button>
|
||||
|
||||
<!-- 清除全部 -->
|
||||
<button
|
||||
class="ml-auto text-xs text-gray-400 hover:text-red-500"
|
||||
@click="emit('clear-all')"
|
||||
>
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 聊天记录查看器 Drawer
|
||||
* 主组件,组合筛选面板、消息列表等子组件
|
||||
*/
|
||||
import { ref, watch, computed, toRaw, nextTick } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import FilterPanel from './FilterPanel.vue'
|
||||
import ActiveFilters from './ActiveFilters.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import type { ChatRecordQuery } from './types'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// 消息列表组件引用
|
||||
const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
||||
|
||||
// 本地查询条件(可编辑的副本)
|
||||
const localQuery = ref<ChatRecordQuery>({})
|
||||
|
||||
// 筛选面板是否展开
|
||||
const filterExpanded = ref(false)
|
||||
|
||||
// 消息数量
|
||||
const messageCount = ref(0)
|
||||
|
||||
// 计算是否有任何筛选条件
|
||||
const hasActiveFilters = computed(() => {
|
||||
const q = localQuery.value
|
||||
return !!(q.scrollToMessageId || q.memberId || q.memberName || q.startTs || q.endTs || q.keywords?.length)
|
||||
})
|
||||
|
||||
// 应用筛选
|
||||
function handleApplyFilter(query: ChatRecordQuery) {
|
||||
localQuery.value = query
|
||||
filterExpanded.value = false
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
function handleResetFilter() {
|
||||
localQuery.value = {}
|
||||
filterExpanded.value = false
|
||||
}
|
||||
|
||||
// 移除单个筛选条件
|
||||
function handleRemoveFilter(key: keyof ChatRecordQuery) {
|
||||
const newQuery = { ...localQuery.value }
|
||||
delete newQuery[key]
|
||||
if (key === 'keywords') {
|
||||
delete newQuery.highlightKeywords
|
||||
}
|
||||
if (key === 'memberId') {
|
||||
delete newQuery.memberName
|
||||
}
|
||||
localQuery.value = newQuery
|
||||
}
|
||||
|
||||
// 清除所有筛选
|
||||
function handleClearAll() {
|
||||
localQuery.value = {}
|
||||
}
|
||||
|
||||
// 切换筛选面板
|
||||
function toggleFilterPanel() {
|
||||
filterExpanded.value = !filterExpanded.value
|
||||
}
|
||||
|
||||
// 处理消息数量变化
|
||||
function handleCountChange(count: number) {
|
||||
messageCount.value = count
|
||||
}
|
||||
|
||||
// 监听 Drawer 打开
|
||||
watch(
|
||||
() => chatStore.showChatRecordDrawer,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
// 复制查询参数到本地
|
||||
const query = toRaw(chatStore.chatRecordQuery)
|
||||
localQuery.value = query ? { ...query } : {}
|
||||
// 如果有外部传入的筛选条件,默认不展开筛选面板
|
||||
filterExpanded.value = false
|
||||
// 等待 DOM 更新后主动触发加载
|
||||
await nextTick()
|
||||
messageListRef.value?.refresh()
|
||||
} else {
|
||||
// 关闭时清理
|
||||
localQuery.value = {}
|
||||
filterExpanded.value = false
|
||||
messageCount.value = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDrawer v-model:open="chatStore.showChatRecordDrawer" direction="right" :handle="false">
|
||||
<template #content>
|
||||
<div class="flex h-full w-[580px] flex-col bg-white dark:bg-gray-900">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">聊天记录查看器</h3>
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="chatStore.closeChatRecordDrawer()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 筛选面板 -->
|
||||
<FilterPanel
|
||||
:query="localQuery"
|
||||
:expanded="filterExpanded"
|
||||
@apply="handleApplyFilter"
|
||||
@reset="handleResetFilter"
|
||||
@toggle="toggleFilterPanel"
|
||||
/>
|
||||
|
||||
<!-- 当前激活的筛选条件 -->
|
||||
<ActiveFilters
|
||||
v-if="hasActiveFilters && !filterExpanded"
|
||||
:query="localQuery"
|
||||
@remove="handleRemoveFilter"
|
||||
@clear-all="handleClearAll"
|
||||
/>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<MessageList
|
||||
ref="messageListRef"
|
||||
:query="localQuery"
|
||||
@count-change="handleCountChange"
|
||||
/>
|
||||
|
||||
<!-- 底部统计 -->
|
||||
<div v-if="messageCount > 0" class="border-t border-gray-200 px-4 py-2 dark:border-gray-800">
|
||||
<span class="text-xs text-gray-500">已加载 {{ messageCount }} 条消息</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 聊天记录筛选面板
|
||||
* 支持消息ID、成员、时间范围、关键词的组合筛选
|
||||
*/
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRecordQuery, FilterFormData } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前查询条件 */
|
||||
query: ChatRecordQuery
|
||||
/** 是否展开 */
|
||||
expanded?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 应用筛选 */
|
||||
(e: 'apply', query: ChatRecordQuery): void
|
||||
/** 重置筛选 */
|
||||
(e: 'reset'): void
|
||||
/** 切换展开状态 */
|
||||
(e: 'toggle'): void
|
||||
}>()
|
||||
|
||||
// 本地表单数据
|
||||
const formData = ref<FilterFormData>({
|
||||
messageId: '',
|
||||
memberName: '',
|
||||
keywords: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
|
||||
// 是否有输入
|
||||
const hasInput = computed(() => {
|
||||
const f = formData.value
|
||||
return !!(f.messageId || f.memberName || f.keywords || f.startDate || f.endDate)
|
||||
})
|
||||
|
||||
// 同步外部 query 到表单
|
||||
watch(
|
||||
() => props.query,
|
||||
(query) => {
|
||||
if (query) {
|
||||
formData.value = {
|
||||
messageId: query.scrollToMessageId?.toString() || '',
|
||||
memberName: query.memberName || '',
|
||||
keywords: query.keywords?.join(', ') || '',
|
||||
startDate: query.startTs ? dayjs.unix(query.startTs).format('YYYY-MM-DD') : '',
|
||||
endDate: query.endTs ? dayjs.unix(query.endTs).format('YYYY-MM-DD') : '',
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 应用筛选
|
||||
function applyFilter() {
|
||||
const f = formData.value
|
||||
const query: ChatRecordQuery = {}
|
||||
|
||||
// 消息 ID
|
||||
if (f.messageId) {
|
||||
const id = parseInt(f.messageId, 10)
|
||||
if (!isNaN(id)) {
|
||||
query.scrollToMessageId = id
|
||||
}
|
||||
}
|
||||
|
||||
// 成员名称(需要后续通过 API 获取成员 ID)
|
||||
if (f.memberName) {
|
||||
query.memberName = f.memberName
|
||||
// TODO: 这里可以添加成员搜索功能
|
||||
}
|
||||
|
||||
// 关键词
|
||||
if (f.keywords) {
|
||||
const keywords = f.keywords
|
||||
.split(/[,,]/)
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k)
|
||||
if (keywords.length > 0) {
|
||||
query.keywords = keywords
|
||||
query.highlightKeywords = keywords
|
||||
}
|
||||
}
|
||||
|
||||
// 时间范围
|
||||
if (f.startDate) {
|
||||
query.startTs = dayjs(f.startDate).startOf('day').unix()
|
||||
}
|
||||
if (f.endDate) {
|
||||
query.endTs = dayjs(f.endDate).endOf('day').unix()
|
||||
}
|
||||
|
||||
emit('apply', query)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
function resetFilter() {
|
||||
formData.value = {
|
||||
messageId: '',
|
||||
memberName: '',
|
||||
keywords: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
}
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-gray-200 dark:border-gray-800">
|
||||
<!-- 折叠头部 -->
|
||||
<button
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-funnel" class="h-4 w-4" />
|
||||
<span>筛选条件</span>
|
||||
<span
|
||||
v-if="hasInput"
|
||||
class="rounded-full bg-blue-500 px-1.5 py-0.5 text-xs font-medium text-white"
|
||||
>
|
||||
已设置
|
||||
</span>
|
||||
</span>
|
||||
<UIcon
|
||||
:name="expanded ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
|
||||
class="h-4 w-4 transition-transform"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 展开的筛选表单 -->
|
||||
<div v-if="expanded" class="space-y-3 px-4 pb-4">
|
||||
<!-- 消息 ID -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-16 shrink-0 text-xs text-gray-500">消息 ID</label>
|
||||
<UInput
|
||||
v-model="formData.messageId"
|
||||
type="number"
|
||||
placeholder="输入消息 ID 定位"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 成员 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-16 shrink-0 text-xs text-gray-500">成员</label>
|
||||
<UInput
|
||||
v-model="formData.memberName"
|
||||
placeholder="成员名称(暂不支持)"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 关键词 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-16 shrink-0 text-xs text-gray-500">关键词</label>
|
||||
<UInput
|
||||
v-model="formData.keywords"
|
||||
placeholder="多个用逗号分隔"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="w-16 shrink-0 text-xs text-gray-500">时间</label>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<UInput v-model="formData.startDate" type="date" size="sm" class="flex-1" />
|
||||
<span class="text-gray-400">~</span>
|
||||
<UInput v-model="formData.endDate" type="date" size="sm" class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<UButton color="neutral" variant="ghost" size="sm" @click="resetFilter">
|
||||
重置
|
||||
</UButton>
|
||||
<UButton color="primary" size="sm" @click="applyFilter">
|
||||
应用筛选
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 单条消息展示组件
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRecordMessage } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 消息数据 */
|
||||
message: ChatRecordMessage
|
||||
/** 是否为目标消息(需要高亮) */
|
||||
isTarget?: boolean
|
||||
/** 高亮关键词 */
|
||||
highlightKeywords?: string[]
|
||||
}>()
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timestamp: number): string {
|
||||
return dayjs.unix(timestamp).format('MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
function formatFullTime(timestamp: number): string {
|
||||
return dayjs.unix(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 高亮关键词
|
||||
function highlightContent(content: string): string {
|
||||
if (!props.highlightKeywords?.length || !content) return content
|
||||
|
||||
const pattern = props.highlightKeywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||
const regex = new RegExp(`(${pattern})`, 'gi')
|
||||
return content.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800/50 px-0.5 rounded">$1</mark>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="px-4 py-3 transition-colors"
|
||||
:class="{
|
||||
'bg-yellow-50 ring-2 ring-inset ring-yellow-400 dark:bg-yellow-900/20 dark:ring-yellow-600': isTarget,
|
||||
}"
|
||||
>
|
||||
<!-- 消息头部 -->
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ message.senderName }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400" :title="formatFullTime(message.timestamp)">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<p
|
||||
class="whitespace-pre-wrap break-words text-sm text-gray-600 dark:text-gray-400"
|
||||
v-html="highlightContent(message.content || '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 消息列表组件
|
||||
* 支持无限滚动加载
|
||||
*/
|
||||
import { ref, watch, nextTick, toRaw } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import type { ChatRecordMessage, ChatRecordQuery } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前查询条件 */
|
||||
query: ChatRecordQuery
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 消息数量变化 */
|
||||
(e: 'count-change', count: number): void
|
||||
}>()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// 消息列表
|
||||
const messages = ref<ChatRecordMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const hasMoreBefore = ref(false)
|
||||
const hasMoreAfter = ref(false)
|
||||
|
||||
// 滚动容器引用
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 构建筛选参数
|
||||
function buildFilterParams(query: ChatRecordQuery) {
|
||||
return {
|
||||
filter: query.startTs || query.endTs ? { startTs: query.startTs, endTs: query.endTs } : undefined,
|
||||
senderId: query.memberId,
|
||||
keywords: query.keywords ? [...toRaw(query.keywords)] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载消息
|
||||
async function loadInitialMessages() {
|
||||
const sessionId = chatStore.currentSessionId
|
||||
if (!sessionId) {
|
||||
messages.value = []
|
||||
emit('count-change', 0)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
messages.value = []
|
||||
|
||||
try {
|
||||
const query = toRaw(props.query)
|
||||
const { filter, senderId, keywords } = buildFilterParams(query)
|
||||
const targetId = query.scrollToMessageId
|
||||
|
||||
if (targetId) {
|
||||
// 以目标消息为中心,加载前后各 50 条
|
||||
const [beforeResult, afterResult] = await Promise.all([
|
||||
window.aiApi.getMessagesBefore(sessionId, targetId, 50, filter, senderId, keywords),
|
||||
window.aiApi.getMessagesAfter(sessionId, targetId, 50, filter, senderId, keywords),
|
||||
])
|
||||
|
||||
// 获取目标消息本身
|
||||
const targetMessages = await window.aiApi.getMessageContext(sessionId, targetId, 0)
|
||||
|
||||
// 合并消息列表
|
||||
messages.value = [...beforeResult.messages, ...targetMessages, ...afterResult.messages]
|
||||
|
||||
hasMoreBefore.value = beforeResult.hasMore
|
||||
hasMoreAfter.value = afterResult.hasMore
|
||||
|
||||
// 滚动到目标消息(延时确保 DOM 完全渲染)
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
scrollToMessage(targetId)
|
||||
}, 100)
|
||||
} else {
|
||||
// 没有目标消息,加载最新的 100 条
|
||||
const result = await window.aiApi.getRecentMessages(sessionId, filter, 100)
|
||||
messages.value = result.messages
|
||||
hasMoreBefore.value = result.messages.length >= 100
|
||||
hasMoreAfter.value = false
|
||||
}
|
||||
|
||||
emit('count-change', messages.value.length)
|
||||
} catch (e) {
|
||||
console.error('加载消息失败:', e)
|
||||
messages.value = []
|
||||
emit('count-change', 0)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更早的消息(向上滚动)
|
||||
async function loadMoreBefore() {
|
||||
if (isLoadingMore.value || !hasMoreBefore.value || messages.value.length === 0) return
|
||||
|
||||
const sessionId = chatStore.currentSessionId
|
||||
if (!sessionId) return
|
||||
|
||||
const firstMessage = messages.value[0]
|
||||
if (!firstMessage) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
|
||||
try {
|
||||
const query = toRaw(props.query)
|
||||
const { filter, senderId, keywords } = buildFilterParams(query)
|
||||
const result = await window.aiApi.getMessagesBefore(sessionId, firstMessage.id, 50, filter, senderId, keywords)
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
// 记录当前滚动位置
|
||||
const container = scrollContainerRef.value
|
||||
const oldScrollHeight = container?.scrollHeight || 0
|
||||
|
||||
// prepend 消息
|
||||
messages.value = [...result.messages, ...messages.value]
|
||||
|
||||
// 恢复滚动位置
|
||||
await nextTick()
|
||||
if (container) {
|
||||
const newScrollHeight = container.scrollHeight
|
||||
container.scrollTop = newScrollHeight - oldScrollHeight
|
||||
}
|
||||
|
||||
emit('count-change', messages.value.length)
|
||||
}
|
||||
|
||||
hasMoreBefore.value = result.hasMore
|
||||
} catch (e) {
|
||||
console.error('加载更早消息失败:', e)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更新的消息(向下滚动)
|
||||
async function loadMoreAfter() {
|
||||
if (isLoadingMore.value || !hasMoreAfter.value || messages.value.length === 0) return
|
||||
|
||||
const sessionId = chatStore.currentSessionId
|
||||
if (!sessionId) return
|
||||
|
||||
const lastMessage = messages.value[messages.value.length - 1]
|
||||
if (!lastMessage) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
|
||||
try {
|
||||
const query = toRaw(props.query)
|
||||
const { filter, senderId, keywords } = buildFilterParams(query)
|
||||
const result = await window.aiApi.getMessagesAfter(sessionId, lastMessage.id, 50, filter, senderId, keywords)
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
messages.value = [...messages.value, ...result.messages]
|
||||
emit('count-change', messages.value.length)
|
||||
}
|
||||
|
||||
hasMoreAfter.value = result.hasMore
|
||||
} catch (e) {
|
||||
console.error('加载更新消息失败:', e)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到指定消息
|
||||
function scrollToMessage(messageId: number) {
|
||||
const container = scrollContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
const messageEl = container.querySelector(`[data-message-id="${messageId}"]`)
|
||||
if (messageEl) {
|
||||
messageEl.scrollIntoView({ block: 'center', behavior: 'auto' })
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚动事件(检测边界)
|
||||
function handleScroll() {
|
||||
const container = scrollContainerRef.value
|
||||
if (!container || isLoadingMore.value) return
|
||||
|
||||
// 接近顶部时加载更多
|
||||
if (container.scrollTop < 100 && hasMoreBefore.value) {
|
||||
loadMoreBefore()
|
||||
}
|
||||
|
||||
// 接近底部时加载更多
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
if (distanceFromBottom < 100 && hasMoreAfter.value) {
|
||||
loadMoreAfter()
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否是目标消息
|
||||
function isTargetMessage(msgId: number): boolean {
|
||||
return msgId === props.query.scrollToMessageId
|
||||
}
|
||||
|
||||
// 监听查询变化
|
||||
watch(
|
||||
() => props.query,
|
||||
() => {
|
||||
loadInitialMessages()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: loadInitialMessages,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isLoading" class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-gray-400" />
|
||||
<p class="mt-2 text-sm text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="messages.length === 0" class="flex h-full items-center justify-center">
|
||||
<div class="text-center">
|
||||
<UIcon name="i-heroicons-chat-bubble-left-right" class="h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<p class="mt-2 text-sm text-gray-500">暂无消息</p>
|
||||
<p class="mt-1 text-xs text-gray-400">尝试调整筛选条件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息滚动容器 -->
|
||||
<div v-else ref="scrollContainerRef" class="h-full overflow-y-auto" @scroll="handleScroll">
|
||||
<!-- 顶部加载指示器 -->
|
||||
<div v-if="hasMoreBefore" class="flex justify-center py-2">
|
||||
<span v-if="isLoadingMore" class="text-xs text-gray-400">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1 inline h-3 w-3 animate-spin" />
|
||||
加载更多...
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-400">↑ 向上滚动加载更多</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<MessageItem
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:data-message-id="msg.id"
|
||||
:message="msg"
|
||||
:is-target="isTargetMessage(msg.id)"
|
||||
:highlight-keywords="query.highlightKeywords"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部加载指示器 -->
|
||||
<div v-if="hasMoreAfter" class="flex justify-center py-2">
|
||||
<span v-if="isLoadingMore" class="text-xs text-gray-400">
|
||||
<UIcon name="i-heroicons-arrow-path" class="mr-1 inline h-3 w-3 animate-spin" />
|
||||
加载更多...
|
||||
</span>
|
||||
<span v-else class="text-xs text-gray-400">↓ 向下滚动加载更多</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 聊天记录查看器组件
|
||||
* 导出主 Drawer 组件和相关类型
|
||||
*/
|
||||
|
||||
export { default as ChatRecordDrawer } from './ChatRecordDrawer.vue'
|
||||
export * from './types'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+36
-1
@@ -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<ChatRecordQuery | null>(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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user