diff --git a/electron/main/ipc/messages.ts b/electron/main/ipc/messages.ts index 8a972e5..b7a0b74 100644 --- a/electron/main/ipc/messages.ts +++ b/electron/main/ipc/messages.ts @@ -145,5 +145,40 @@ export function registerMessagesHandlers({ win }: IpcContext): void { } } ) -} + // ==================== 自定义筛选 ==================== + + /** + * 按条件筛选消息并扩充上下文 + */ + ipcMain.handle( + 'ai:filterMessagesWithContext', + async ( + _, + sessionId: string, + keywords?: string[], + timeFilter?: { startTs: number; endTs: number }, + senderIds?: number[], + contextSize?: number + ) => { + try { + return await worker.filterMessagesWithContext(sessionId, keywords, timeFilter, senderIds, contextSize) + } catch (error) { + console.error('筛选消息失败:', error) + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + } + ) + + /** + * 获取多个会话的完整消息 + */ + ipcMain.handle('ai:getMultipleSessionsMessages', async (_, sessionId: string, chatSessionIds: number[]) => { + try { + return await worker.getMultipleSessionsMessages(sessionId, chatSessionIds) + } catch (error) { + console.error('获取多个会话消息失败:', error) + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + }) +} diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index 0815239..d9d60e6 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -56,6 +56,9 @@ import { getSessions, searchSessions, getSessionMessages, + // 自定义筛选 + filterMessagesWithContext, + getMultipleSessionsMessages, } from './query' import { streamImport, streamParseFileInfo } from './import' @@ -134,6 +137,11 @@ const syncHandlers: Record any> = { getSessions: (p) => getSessions(p.sessionId), searchSessions: (p) => searchSessions(p.sessionId, p.keywords, p.timeFilter, p.limit, p.previewCount), getSessionMessages: (p) => getSessionMessages(p.sessionId, p.chatSessionId, p.limit), + + // 自定义筛选 + filterMessagesWithContext: (p) => + filterMessagesWithContext(p.sessionId, p.keywords, p.timeFilter, p.senderIds, p.contextSize), + getMultipleSessionsMessages: (p) => getMultipleSessionsMessages(p.sessionId, p.chatSessionIds), } // 异步消息处理器(流式操作) diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index a86c826..7a8f0f4 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -65,5 +65,15 @@ export { searchSessions, getSessionMessages, DEFAULT_SESSION_GAP_THRESHOLD, + // 自定义筛选 + filterMessagesWithContext, + getMultipleSessionsMessages, +} from './session' +export type { + ChatSessionItem, + SessionSearchResultItem, + SessionMessagesResult, + ContextBlock, + FilterResult, + FilterMessage, } from './session' -export type { ChatSessionItem, SessionSearchResultItem, SessionMessagesResult } from './session' diff --git a/electron/main/worker/query/session.ts b/electron/main/worker/query/session.ts index 50d7ea0..57d922d 100644 --- a/electron/main/worker/query/session.ts +++ b/electron/main/worker/query/session.ts @@ -578,3 +578,372 @@ export function getSessionMessages( db.close() } } + +// ==================== 自定义筛选专用函数 ==================== + +/** + * 自定义筛选消息类型(完整信息,兼容 MessageList 组件) + */ +export interface FilterMessage { + id: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + /** 是否为命中的消息(关键词匹配) */ + isHit: boolean +} + +/** + * 上下文块类型(用于自定义筛选) + */ +export interface ContextBlock { + /** 块的时间范围 */ + startTs: number + endTs: number + /** 消息列表 */ + messages: FilterMessage[] + /** 命中的消息数量 */ + hitCount: number +} + +/** + * 筛选结果类型 + */ +export interface FilterResult { + /** 上下文块列表 */ + blocks: ContextBlock[] + /** 统计信息 */ + stats: { + /** 总消息数 */ + totalMessages: number + /** 命中的消息数 */ + hitMessages: number + /** 总字符数 */ + totalChars: number + } +} + +/** + * 按条件筛选消息并扩充上下文 + * + * 核心算法: + * 1. 先搜索匹配条件的消息,获取消息ID列表 + * 2. 为每个命中消息向前后各扩展 contextSize 条消息 + * 3. 合并重叠/相邻的消息范围 + * 4. 按合并后的范围分块返回消息 + * + * @param sessionId 数据库会话ID + * @param keywords 关键词列表(可选,OR 逻辑) + * @param timeFilter 时间过滤器(可选) + * @param senderIds 发送者ID列表(可选) + * @param contextSize 上下文扩展数量(前后各多少条) + * @returns 筛选结果 + */ +export function filterMessagesWithContext( + sessionId: string, + keywords?: string[], + timeFilter?: { startTs: number; endTs: number }, + senderIds?: number[], + contextSize: number = 10 +): FilterResult { + const db = openReadonlyDatabase(sessionId) + if (!db) { + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + + try { + // 1. 构建基础消息查询(完整信息),按时间排序 + // 使用 LEFT JOIN 获取回复消息的信息 + const allMessagesSql = ` + SELECT + msg.id, + msg.ts, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + COALESCE(m.aliases, '[]') as senderAliasesJson, + m.avatar as senderAvatar, + msg.content, + msg.type, + msg.reply_to_message_id as replyToMessageId, + reply_msg.content as replyToContent, + COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName, + msg.sender_id as senderId + FROM message msg + JOIN member m ON msg.sender_id = m.id + LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id + LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id + ${timeFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''} + ORDER BY msg.ts ASC, msg.id ASC + ` + + const params: unknown[] = [] + if (timeFilter) { + params.push(timeFilter.startTs, timeFilter.endTs) + } + + const allMessages = db.prepare(allMessagesSql).all(...params) as Array<{ + id: number + ts: number + senderName: string + senderPlatformId: string + senderAliasesJson: string + senderAvatar: string | null + content: string | null + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + senderId: number + }> + + if (allMessages.length === 0) { + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + + // 2. 标记命中的消息 + const hitIndexes: number[] = [] + for (let i = 0; i < allMessages.length; i++) { + const msg = allMessages[i] + let isHit = true + + // 关键词匹配(OR 逻辑) + if (keywords && keywords.length > 0) { + const content = (msg.content || '').toLowerCase() + isHit = keywords.some((kw) => content.includes(kw.toLowerCase())) + } + + // 发送者匹配 + if (isHit && senderIds && senderIds.length > 0) { + isHit = senderIds.includes(msg.senderId) + } + + if (isHit) { + hitIndexes.push(i) + } + } + + if (hitIndexes.length === 0) { + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + + // 3. 扩展上下文并合并重叠范围 + const ranges: Array<{ start: number; end: number; hitIndexes: number[] }> = [] + + for (const hitIndex of hitIndexes) { + const start = Math.max(0, hitIndex - contextSize) + const end = Math.min(allMessages.length - 1, hitIndex + contextSize) + + // 检查是否能与前一个范围合并 + if (ranges.length > 0) { + const lastRange = ranges[ranges.length - 1] + // 如果当前范围的 start <= 上一个范围的 end + 1,则合并 + if (start <= lastRange.end + 1) { + lastRange.end = Math.max(lastRange.end, end) + lastRange.hitIndexes.push(hitIndex) + continue + } + } + + ranges.push({ start, end, hitIndexes: [hitIndex] }) + } + + // 4. 按范围构建上下文块 + const blocks: ContextBlock[] = [] + let totalMessages = 0 + let totalChars = 0 + + for (const range of ranges) { + const hitIndexSet = new Set(range.hitIndexes) + const blockMessages: FilterMessage[] = [] + + for (let i = range.start; i <= range.end; i++) { + const msg = allMessages[i] + const isHit = hitIndexSet.has(i) + + // 解析别名 JSON + let senderAliases: string[] = [] + try { + senderAliases = JSON.parse(msg.senderAliasesJson || '[]') + } catch { + senderAliases = [] + } + + blockMessages.push({ + id: msg.id, + senderName: msg.senderName, + senderPlatformId: msg.senderPlatformId, + senderAliases, + senderAvatar: msg.senderAvatar, + content: msg.content || '', + timestamp: msg.ts, + type: msg.type, + replyToMessageId: msg.replyToMessageId, + replyToContent: msg.replyToContent, + replyToSenderName: msg.replyToSenderName, + isHit, + }) + totalChars += (msg.content || '').length + } + + blocks.push({ + startTs: allMessages[range.start].ts, + endTs: allMessages[range.end].ts, + messages: blockMessages, + hitCount: range.hitIndexes.length, + }) + + totalMessages += blockMessages.length + } + + return { + blocks, + stats: { + totalMessages, + hitMessages: hitIndexes.length, + totalChars, + }, + } + } catch (error) { + console.error('filterMessagesWithContext error:', error) + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } finally { + db.close() + } +} + +/** + * 获取多个会话的完整消息(用于会话筛选模式) + * + * @param sessionId 数据库会话ID + * @param chatSessionIds 要获取的会话ID列表 + * @returns 合并后的上下文块和统计 + */ +export function getMultipleSessionsMessages(sessionId: string, chatSessionIds: number[]): FilterResult { + const db = openReadonlyDatabase(sessionId) + if (!db) { + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + + try { + if (chatSessionIds.length === 0) { + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } + + const blocks: ContextBlock[] = [] + let totalMessages = 0 + let totalChars = 0 + + // 先获取会话信息,按时间排序 + const sessionsSql = ` + SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount + FROM chat_session + WHERE id IN (${chatSessionIds.map(() => '?').join(',')}) + ORDER BY start_ts ASC + ` + const sessions = db.prepare(sessionsSql).all(...chatSessionIds) as Array<{ + id: number + startTs: number + endTs: number + messageCount: number + }> + + // 为每个会话获取消息(完整信息) + // 使用 LEFT JOIN 获取回复消息的信息 + const messagesSql = ` + SELECT + msg.id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + COALESCE(m.aliases, '[]') as senderAliasesJson, + m.avatar as senderAvatar, + msg.content, + msg.type, + msg.reply_to_message_id as replyToMessageId, + reply_msg.content as replyToContent, + COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName, + msg.ts as timestamp + FROM message_context mc + JOIN message msg ON msg.id = mc.message_id + JOIN member m ON msg.sender_id = m.id + LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id + LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id + WHERE mc.session_id = ? + ORDER BY msg.ts ASC + ` + + for (const session of sessions) { + const messages = db.prepare(messagesSql).all(session.id) as Array<{ + id: number + senderName: string + senderPlatformId: string + senderAliasesJson: string + senderAvatar: string | null + content: string | null + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + timestamp: number + }> + + const blockMessages: FilterMessage[] = messages.map((msg) => { + // 解析别名 JSON + let senderAliases: string[] = [] + try { + senderAliases = JSON.parse(msg.senderAliasesJson || '[]') + } catch { + senderAliases = [] + } + + return { + id: msg.id, + senderName: msg.senderName, + senderPlatformId: msg.senderPlatformId, + senderAliases, + senderAvatar: msg.senderAvatar, + content: msg.content || '', + timestamp: msg.timestamp, + type: msg.type, + replyToMessageId: msg.replyToMessageId, + replyToContent: msg.replyToContent, + replyToSenderName: msg.replyToSenderName, + isHit: false, // 会话模式下没有命中高亮 + } + }) + + for (const msg of messages) { + totalChars += (msg.content || '').length + } + + blocks.push({ + startTs: session.startTs, + endTs: session.endTs, + messages: blockMessages, + hitCount: 0, + }) + + totalMessages += messages.length + } + + return { + blocks, + stats: { + totalMessages, + hitMessages: 0, // 会话模式没有命中概念 + totalChars, + }, + } + } catch (error) { + console.error('getMultipleSessionsMessages error:', error) + return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } } + } finally { + db.close() + } +} diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 0e97ad6..2d66b60 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -618,3 +618,65 @@ export async function getSessionMessages( ): Promise { return sendToWorker('getSessionMessages', { sessionId, chatSessionId, limit }) } + +// ==================== 自定义筛选 API ==================== + +/** + * 筛选消息类型(完整信息) + */ +export interface FilterMessage { + id: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + isHit: boolean +} + +/** + * 上下文块类型 + */ +export interface ContextBlock { + startTs: number + endTs: number + messages: FilterMessage[] + hitCount: number +} + +/** + * 筛选结果类型 + */ +export interface FilterResult { + blocks: ContextBlock[] + stats: { + totalMessages: number + hitMessages: number + totalChars: number + } +} + +/** + * 按条件筛选消息并扩充上下文 + */ +export async function filterMessagesWithContext( + sessionId: string, + keywords?: string[], + timeFilter?: { startTs: number; endTs: number }, + senderIds?: number[], + contextSize?: number +): Promise { + return sendToWorker('filterMessagesWithContext', { sessionId, keywords, timeFilter, senderIds, contextSize }) +} + +/** + * 获取多个会话的完整消息 + */ +export async function getMultipleSessionsMessages(sessionId: string, chatSessionIds: number[]): Promise { + return sendToWorker('getMultipleSessionsMessages', { sessionId, chatSessionIds }) +} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 437a6f1..85ca39f 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -146,6 +146,37 @@ interface SearchMessageResult { type: number } +interface FilterMessage { + id: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + isHit: boolean +} + +interface ContextBlock { + startTs: number + endTs: number + messages: FilterMessage[] + hitCount: number +} + +interface FilterResult { + blocks: ContextBlock[] + stats: { + totalMessages: number + hitMessages: number + totalChars: number + } +} + interface AIConversation { id: string sessionId: string @@ -239,7 +270,17 @@ interface AiApi { contentBlocks?: AIContentBlock[] ) => Promise getMessages: (conversationId: string) => Promise + getMessages: (conversationId: string) => Promise deleteMessage: (messageId: string) => Promise + // 自定义筛选 + filterMessagesWithContext: ( + sessionId: string, + keywords?: string[], + timeFilter?: TimeFilter, + senderIds?: number[], + contextSize?: number + ) => Promise + getMultipleSessionsMessages: (sessionId: string, chatSessionIds: number[]) => Promise } // LLM 相关类型 @@ -504,4 +545,7 @@ export { TokenUsage, CacheDirectoryInfo, CacheInfo, + FilterMessage, + ContextBlock, + FilterResult, } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 7c8e197..099136c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -563,6 +563,81 @@ const aiApi = { return ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords) }, + // ==================== 自定义筛选 ==================== + + /** + * 按条件筛选消息并扩充上下文 + */ + filterMessagesWithContext: ( + sessionId: string, + keywords?: string[], + timeFilter?: { startTs: number; endTs: number }, + senderIds?: number[], + contextSize?: number + ): Promise<{ + blocks: Array<{ + startTs: number + endTs: number + messages: Array<{ + id: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + isHit: boolean + }> + hitCount: number + }> + stats: { + totalMessages: number + hitMessages: number + totalChars: number + } + }> => { + return ipcRenderer.invoke('ai:filterMessagesWithContext', sessionId, keywords, timeFilter, senderIds, contextSize) + }, + + /** + * 获取多个会话的完整消息 + */ + getMultipleSessionsMessages: ( + sessionId: string, + chatSessionIds: number[] + ): Promise<{ + blocks: Array<{ + startTs: number + endTs: number + messages: Array<{ + id: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null + isHit: boolean + }> + hitCount: number + }> + stats: { + totalMessages: number + hitMessages: number + totalChars: number + } + }> => { + return ipcRenderer.invoke('ai:getMultipleSessionsMessages', sessionId, chatSessionIds) + }, + /** * 创建 AI 对话 */ diff --git a/src/components.d.ts b/src/components.d.ts index fb1c03e..4f1a7d1 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { UApp: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/App.vue')['default'] UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default'] UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default'] + UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default'] UChatPrompt: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/ChatPrompt.vue')['default'] UChatPromptSubmit: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/ChatPromptSubmit.vue')['default'] UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.3.0_@babel+parser@7.28.5_@floating-ui+dom@1.7.4_@tiptap+extension-drag-handl_a9a750d621383b428aaeaf8a849d7a17/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default'] diff --git a/src/components/analysis/AITab.vue b/src/components/analysis/AITab.vue index c908e7c..424468e 100644 --- a/src/components/analysis/AITab.vue +++ b/src/components/analysis/AITab.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import { SubTabs } from '@/components/UI' import ChatExplorer from './ai/ChatExplorer.vue' import SQLLabTab from './SQLLabTab.vue' +import FilterTab from './Filter/FilterTab.vue' const { t, locale } = useI18n() @@ -41,13 +42,13 @@ const groupOnlyTabs = ['mbti', 'cyber-friend', 'campus'] // 所有子 Tab 配置 const allSubTabs = computed(() => [ { id: 'chat-explorer', label: t('chatExplorer'), icon: 'i-heroicons-chat-bubble-left-ellipsis' }, - { id: 'sql-lab', label: t('sqlLab'), icon: 'i-heroicons-command-line' }, { id: 'manual', label: t('filterAnalysis'), desc: t('filterAnalysisDesc'), icon: 'i-heroicons-adjustments-horizontal', }, + { id: 'sql-lab', label: t('sqlLab'), icon: 'i-heroicons-command-line' }, ]) // 根据聊天类型过滤显示的子 Tab @@ -94,10 +95,14 @@ defineExpose({ :time-filter="timeFilter" :chat-type="chatType" /> + + + +
- - - @@ -152,7 +154,7 @@ defineExpose({ "zh-CN": { "chatExplorer": "对话式探索", "sqlLab": "SQL实验室", - "filterAnalysis": "筛选分析", + "filterAnalysis": "自定义筛选", "filterAnalysisDesc": "计划实现高级筛选功能,可以先按人/按时间/按搜索内容手动筛选,然后再进行AI分析", "featureInDev": "{name}功能开发中", "comingSoon": "敬请期待...", diff --git a/src/components/analysis/Filter/ConditionPanel.vue b/src/components/analysis/Filter/ConditionPanel.vue new file mode 100644 index 0000000..0d916fa --- /dev/null +++ b/src/components/analysis/Filter/ConditionPanel.vue @@ -0,0 +1,307 @@ + + + diff --git a/src/components/analysis/Filter/FilterHistory.vue b/src/components/analysis/Filter/FilterHistory.vue new file mode 100644 index 0000000..693fce5 --- /dev/null +++ b/src/components/analysis/Filter/FilterHistory.vue @@ -0,0 +1,240 @@ + + + + diff --git a/src/components/analysis/Filter/FilterTab.vue b/src/components/analysis/Filter/FilterTab.vue new file mode 100644 index 0000000..b03e947 --- /dev/null +++ b/src/components/analysis/Filter/FilterTab.vue @@ -0,0 +1,341 @@ + + + diff --git a/src/components/analysis/Filter/LocalAnalysisModal.vue b/src/components/analysis/Filter/LocalAnalysisModal.vue new file mode 100644 index 0000000..a7649a0 --- /dev/null +++ b/src/components/analysis/Filter/LocalAnalysisModal.vue @@ -0,0 +1,337 @@ + + + diff --git a/src/components/analysis/Filter/PreviewPanel.vue b/src/components/analysis/Filter/PreviewPanel.vue new file mode 100644 index 0000000..a5b2916 --- /dev/null +++ b/src/components/analysis/Filter/PreviewPanel.vue @@ -0,0 +1,408 @@ + + + diff --git a/src/components/analysis/Filter/SessionPanel.vue b/src/components/analysis/Filter/SessionPanel.vue new file mode 100644 index 0000000..9d13a7e --- /dev/null +++ b/src/components/analysis/Filter/SessionPanel.vue @@ -0,0 +1,274 @@ + + + diff --git a/src/components/common/ChatRecord/MessageList.vue b/src/components/common/ChatRecord/MessageList.vue index db3f733..611efd4 100644 --- a/src/components/common/ChatRecord/MessageList.vue +++ b/src/components/common/ChatRecord/MessageList.vue @@ -16,10 +16,23 @@ const TIME_SEPARATOR_THRESHOLD = 5 * 60 // 5 分钟 const { t } = useI18n() -const props = defineProps<{ - /** 当前查询条件 */ - query: ChatRecordQuery -}>() +const props = withDefaults( + defineProps<{ + /** 当前查询条件 */ + query: ChatRecordQuery + /** 外部传入的消息列表(可选,传入后不自动加载) */ + externalMessages?: ChatRecordMessage[] + /** 外部传入时需要高亮的消息 ID 列表(命中的消息) */ + hitMessageIds?: number[] + /** 外部消息变化时的滚动行为:top=滚动到顶部,preserve=保持当前位置 */ + externalScrollBehavior?: 'top' | 'preserve' + }>(), + { + externalMessages: undefined, + hitMessageIds: () => [], + externalScrollBehavior: 'top', + } +) const emit = defineEmits<{ /** 消息数量变化 */ @@ -28,13 +41,22 @@ const emit = defineEmits<{ (e: 'visible-message-change', messageId: number): void /** 跳转到指定消息(用于查看上下文) */ (e: 'jump-to-message', messageId: number): void + /** 滚动到底部(外部模式专用,用于加载下一个块) */ + (e: 'reach-bottom'): void + /** 滚动到顶部(外部模式专用,用于加载上一个块) */ + (e: 'reach-top'): void }>() const sessionStore = useSessionStore() +// 判断是否使用外部传入的消息 +const isExternalMode = computed(() => !!props.externalMessages?.length) + // 判断是否处于筛选模式(有筛选条件且消息不连贯时显示上下文按钮) // 注意:通过消息 ID 定位时上下文是连贯的,不需要显示 +// 外部模式下不显示上下文按钮(数据已经处理好) const isFiltered = computed(() => { + if (isExternalMode.value) return false const q = props.query // 只有关键词、成员筛选时才需要显示上下文按钮 return !!(q.memberId || q.keywords?.length) @@ -96,8 +118,35 @@ function mapMessages(messages: any[]): ChatRecordMessage[] { })) as ChatRecordMessage[] } +// 记录上一次消息数量(用于判断是扩展还是替换) +let previousExternalMessageCount = 0 + // 初始加载消息 async function loadInitialMessages() { + // 外部模式:直接使用外部消息 + if (isExternalMode.value) { + const currentCount = props.externalMessages!.length + const isExpanding = previousExternalMessageCount > 0 && currentCount > previousExternalMessageCount + + messages.value = props.externalMessages! + hasMoreBefore.value = false + hasMoreAfter.value = false + isSearchMode.value = false + emit('count-change', messages.value.length) + + previousExternalMessageCount = currentCount + + await nextTick() + + // 根据滚动行为 prop 和是否是扩展来决定滚动位置 + if (props.externalScrollBehavior === 'preserve' && isExpanding) { + // 保持当前位置(不做任何滚动操作) + } else { + scrollToTop() + } + return + } + const sessionId = sessionStore.currentSessionId if (!sessionId) { messages.value = [] @@ -306,15 +355,26 @@ function handleScroll() { const container = scrollContainerRef.value if (!container) return - // 检测加载更多 - if (!isLoadingMore.value) { + const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight + + // 外部模式:通知到达边界 + if (isExternalMode.value) { + if (container.scrollTop < 50) { + emit('reach-top') + } + if (distanceFromBottom < 50) { + emit('reach-bottom') + } + } + + // 检测加载更多(非外部模式) + if (!isExternalMode.value && !isLoadingMore.value) { // 接近顶部时加载更多 if (container.scrollTop < 100 && hasMoreBefore.value) { loadMoreBefore() } // 接近底部时加载更多 - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight if (distanceFromBottom < 100 && hasMoreAfter.value) { loadMoreAfter() } @@ -355,8 +415,12 @@ function updateVisibleMessage() { } } -// 判断是否是目标消息 +// 判断是否是目标消息(高亮显示) function isTargetMessage(msgId: number): boolean { + // 外部模式:检查是否在命中列表中 + if (isExternalMode.value && props.hitMessageIds?.length) { + return props.hitMessageIds.includes(msgId) + } return msgId === props.query.scrollToMessageId } @@ -419,11 +483,24 @@ function measureElement(el: Element | null) { watch( () => props.query, () => { - loadInitialMessages() + if (!isExternalMode.value) { + loadInitialMessages() + } }, { deep: true } ) +// 监听外部消息变化 +watch( + () => props.externalMessages, + () => { + if (isExternalMode.value) { + loadInitialMessages() + } + }, + { deep: true, immediate: true } +) + // 清理定时器 onUnmounted(() => { if (visibleMessageTimer) { diff --git a/src/i18n/locales/en-US/analysis.json b/src/i18n/locales/en-US/analysis.json index ff9e2a7..a403692 100644 --- a/src/i18n/locales/en-US/analysis.json +++ b/src/i18n/locales/en-US/analysis.json @@ -27,5 +27,60 @@ "tooltip": { "chatViewer": "Chat Record Viewer", "sessionIndex": "Session Index" + }, + "filter": { + "title": "Filter Analysis", + "conditionMode": "Condition Filter", + "sessionMode": "Session Filter", + "history": "History", + "execute": "Execute Filter", + "export": "Export Feed Pack", + "localAnalysis": "AI Analysis", + "keywords": "Keywords", + "keywordsHint": "Multiple keywords supported, OR logic", + "keywordPlaceholder": "Enter keyword, press Enter to add", + "timeRange": "Time Range", + "allTime": "All Time", + "today": "Today", + "lastWeek": "Last 7 Days", + "lastMonth": "Last Month", + "last3Months": "Last 3 Months", + "lastYear": "Last Year", + "customTime": "Custom", + "senders": "Senders", + "sendersHint": "Optional, leave empty for all", + "searchMember": "Search member...", + "noMembers": "No members", + "selectedCount": "{count} selected", + "contextSize": "Context Expansion", + "messages": "messages", + "searchSession": "Search date...", + "selectedSessions": "{count} sessions selected", + "noSessions": "No session index", + "filtering": "Filtering...", + "emptyHint": "Set filter conditions and click execute", + "noResults": "No matching messages found", + "blockTitle": "Context Block {index}", + "stats": { + "blocks": "Blocks", + "messages": "Messages", + "hits": "Hits", + "chars": "Characters", + "tokens": "Est. Tokens" + }, + "tokenWarning": { + "yellow": "Token count is high, may affect AI analysis", + "red": "Token count too high, consider narrowing filter" + }, + "historyTitle": "Filter History", + "noHistory": "No history records", + "localAnalysisTitle": "AI Analysis", + "presetAnalysis": "Preset Analysis", + "customAnalysis": "Custom Question", + "customPromptPlaceholder": "Enter your question...", + "editablePromptLabel": "Prompt (editable)", + "analyzing": "Analyzing...", + "startAnalysis": "Start Analysis", + "analysisResult": "Analysis Result" } } diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index 7a9865d..886a2dd 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -4,6 +4,7 @@ "delete": "Delete", "save": "Save", "edit": "Edit", + "add": "Add", "rename": "Rename", "capture": "Capture", "close": "Close", diff --git a/src/i18n/locales/zh-CN/analysis.json b/src/i18n/locales/zh-CN/analysis.json index 04a6e65..6ec5db6 100644 --- a/src/i18n/locales/zh-CN/analysis.json +++ b/src/i18n/locales/zh-CN/analysis.json @@ -27,5 +27,60 @@ "tooltip": { "chatViewer": "聊天记录查看器", "sessionIndex": "会话索引" + }, + "filter": { + "title": "自定义筛选", + "conditionMode": "条件筛选", + "sessionMode": "会话筛选", + "history": "历史记录", + "execute": "开始筛选", + "export": "导出筛选结果", + "localAnalysis": "AI 分析", + "keywords": "关键词", + "keywordsHint": "支持多个关键词,OR 逻辑匹配", + "keywordPlaceholder": "输入关键词,回车添加", + "timeRange": "时间范围", + "allTime": "全部", + "today": "今天", + "lastWeek": "近7天", + "lastMonth": "近1个月", + "last3Months": "近3个月", + "lastYear": "近1年", + "customTime": "自定义", + "senders": "发送者", + "sendersHint": "可选,留空表示不限", + "searchMember": "搜索成员...", + "noMembers": "暂无成员", + "selectedCount": "已选择 {count} 位", + "contextSize": "上下文扩展", + "messages": "条消息", + "searchSession": "搜索日期...", + "selectedSessions": "已选择 {count} 个会话", + "noSessions": "暂无会话索引", + "filtering": "正在筛选...", + "emptyHint": "设置筛选条件后点击执行", + "noResults": "未找到匹配的消息", + "blockTitle": "对话块 {index}", + "stats": { + "blocks": "对话块", + "messages": "消息数", + "hits": "命中数", + "chars": "字符数", + "tokens": "预估 Token" + }, + "tokenWarning": { + "yellow": "Token 数量较多,可能影响 AI 分析效果", + "red": "Token 数量过多,建议缩小筛选范围" + }, + "historyTitle": "筛选历史", + "noHistory": "暂无历史记录", + "localAnalysisTitle": "AI 分析", + "presetAnalysis": "预设分析", + "customAnalysis": "自定义提问", + "customPromptPlaceholder": "输入你想问的问题...", + "editablePromptLabel": "提示词(可临时修改)", + "analyzing": "分析中...", + "startAnalysis": "开始分析", + "analysisResult": "分析结果" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 93dbeeb..9d6288e 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -4,6 +4,7 @@ "delete": "删除", "save": "保存", "edit": "编辑", + "add": "添加", "rename": "重命名", "capture": "截屏", "close": "关闭",