mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-23 01:39:37 +08:00
fix: 修复windows用户自定义筛选时消息量过大导致软件崩溃的BUG
This commit is contained in:
@@ -149,7 +149,7 @@ export function registerMessagesHandlers({ win }: IpcContext): void {
|
||||
// ==================== 自定义筛选 ====================
|
||||
|
||||
/**
|
||||
* 按条件筛选消息并扩充上下文
|
||||
* 按条件筛选消息并扩充上下文(支持分页)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ai:filterMessagesWithContext',
|
||||
@@ -159,26 +159,78 @@ export function registerMessagesHandlers({ win }: IpcContext): void {
|
||||
keywords?: string[],
|
||||
timeFilter?: { startTs: number; endTs: number },
|
||||
senderIds?: number[],
|
||||
contextSize?: number
|
||||
contextSize?: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => {
|
||||
try {
|
||||
return await worker.filterMessagesWithContext(sessionId, keywords, timeFilter, senderIds, contextSize)
|
||||
return await worker.filterMessagesWithContext(
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
senderIds,
|
||||
contextSize,
|
||||
page,
|
||||
pageSize
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('筛选消息失败:', error)
|
||||
return { blocks: [], stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 } }
|
||||
return {
|
||||
blocks: [],
|
||||
stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 },
|
||||
pagination: { page: page ?? 1, pageSize: pageSize ?? 50, totalBlocks: 0, totalHits: 0, hasMore: false },
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取多个会话的完整消息
|
||||
* 获取多个会话的完整消息(支持分页)
|
||||
*/
|
||||
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 } }
|
||||
ipcMain.handle(
|
||||
'ai:getMultipleSessionsMessages',
|
||||
async (_, sessionId: string, chatSessionIds: number[], page?: number, pageSize?: number) => {
|
||||
try {
|
||||
return await worker.getMultipleSessionsMessages(sessionId, chatSessionIds, page, pageSize)
|
||||
} catch (error) {
|
||||
console.error('获取多个会话消息失败:', error)
|
||||
return {
|
||||
blocks: [],
|
||||
stats: { totalMessages: 0, hitMessages: 0, totalChars: 0 },
|
||||
pagination: { page: page ?? 1, pageSize: pageSize ?? 50, totalBlocks: 0, totalHits: 0, hasMore: false },
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* 导出筛选结果到文件(后端生成,支持进度)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ai:exportFilterResultToFile',
|
||||
async (
|
||||
_,
|
||||
params: {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
outputDir: string
|
||||
filterMode: 'condition' | 'session'
|
||||
keywords?: string[]
|
||||
timeFilter?: { startTs: number; endTs: number }
|
||||
senderIds?: number[]
|
||||
contextSize?: number
|
||||
chatSessionIds?: number[]
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
return await worker.exportFilterResultToFile(params, (progress) => {
|
||||
// 发送进度到渲染进程
|
||||
win.webContents.send('ai:exportProgress', progress)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('导出筛选结果失败:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
// 自定义筛选
|
||||
filterMessagesWithContext,
|
||||
getMultipleSessionsMessages,
|
||||
exportFilterResultToFile,
|
||||
// NLP 查询
|
||||
getWordFrequency,
|
||||
segmentText,
|
||||
@@ -148,10 +149,11 @@ const syncHandlers: Record<string, (payload: any) => any> = {
|
||||
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),
|
||||
filterMessagesWithContext(p.sessionId, p.keywords, p.timeFilter, p.senderIds, p.contextSize, p.page, p.pageSize),
|
||||
getMultipleSessionsMessages: (p) =>
|
||||
getMultipleSessionsMessages(p.sessionId, p.chatSessionIds, p.page, p.pageSize),
|
||||
|
||||
// NLP 查询
|
||||
getWordFrequency: (p) => getWordFrequency(p),
|
||||
@@ -168,6 +170,8 @@ const asyncHandlers: Record<string, (payload: any, requestId: string) => Promise
|
||||
// 增量导入
|
||||
analyzeIncrementalImport: (p, id) => analyzeIncrementalImport(p.sessionId, p.filePath, id),
|
||||
incrementalImport: (p, id) => incrementalImport(p.sessionId, p.filePath, id),
|
||||
// 导出筛选结果到文件(支持进度报告)
|
||||
exportFilterResultToFile: async (p, id) => exportFilterResultToFile(p, id),
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
|
||||
@@ -122,7 +122,7 @@ export function initWorker(): void {
|
||||
/**
|
||||
* 发送消息到 Worker 并等待响应
|
||||
*/
|
||||
function sendToWorker<T>(type: string, payload: any): Promise<T> {
|
||||
function sendToWorker<T>(type: string, payload: any, timeoutMs: number = 30000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!worker) {
|
||||
try {
|
||||
@@ -139,13 +139,13 @@ function sendToWorker<T>(type: string, payload: any): Promise<T> {
|
||||
|
||||
worker!.postMessage({ id, type, payload })
|
||||
|
||||
// 设置超时(30秒)
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id)
|
||||
reject(new Error(`Worker request timeout: ${type}`))
|
||||
}
|
||||
}, 30000)
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -810,23 +810,93 @@ export interface FilterResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件筛选消息并扩充上下文
|
||||
* 分页信息类型
|
||||
*/
|
||||
export interface PaginationInfo {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 带分页的筛选结果类型
|
||||
*/
|
||||
export interface FilterResultWithPagination extends FilterResult {
|
||||
pagination: PaginationInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 按条件筛选消息并扩充上下文(支持分页)
|
||||
*/
|
||||
export async function filterMessagesWithContext(
|
||||
sessionId: string,
|
||||
keywords?: string[],
|
||||
timeFilter?: { startTs: number; endTs: number },
|
||||
senderIds?: number[],
|
||||
contextSize?: number
|
||||
): Promise<FilterResult> {
|
||||
return sendToWorker('filterMessagesWithContext', { sessionId, keywords, timeFilter, senderIds, contextSize })
|
||||
contextSize?: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<FilterResultWithPagination> {
|
||||
return sendToWorker('filterMessagesWithContext', {
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
senderIds,
|
||||
contextSize,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个会话的完整消息
|
||||
* 获取多个会话的完整消息(支持分页)
|
||||
*/
|
||||
export async function getMultipleSessionsMessages(sessionId: string, chatSessionIds: number[]): Promise<FilterResult> {
|
||||
return sendToWorker('getMultipleSessionsMessages', { sessionId, chatSessionIds })
|
||||
export async function getMultipleSessionsMessages(
|
||||
sessionId: string,
|
||||
chatSessionIds: number[],
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<FilterResultWithPagination> {
|
||||
return sendToWorker('getMultipleSessionsMessages', { sessionId, chatSessionIds, page, pageSize })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出筛选结果参数
|
||||
*/
|
||||
export interface ExportFilterParams {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
outputDir: string
|
||||
filterMode: 'condition' | 'session'
|
||||
keywords?: string[]
|
||||
timeFilter?: { startTs: number; endTs: number }
|
||||
senderIds?: number[]
|
||||
contextSize?: number
|
||||
chatSessionIds?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出进度回调类型
|
||||
*/
|
||||
export interface ExportProgress {
|
||||
stage: 'preparing' | 'exporting' | 'done' | 'error'
|
||||
currentBlock: number
|
||||
totalBlocks: number
|
||||
percentage: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出筛选结果到文件(后端生成)
|
||||
* 使用 10 分钟超时,支持大数据量导出和进度回调
|
||||
*/
|
||||
export async function exportFilterResultToFile(
|
||||
params: ExportFilterParams,
|
||||
onProgress?: (progress: ExportProgress) => void
|
||||
): Promise<{ success: boolean; filePath?: string; error?: string }> {
|
||||
return sendToWorkerWithProgress('exportFilterResultToFile', params, onProgress as any, 600000)
|
||||
}
|
||||
|
||||
// ==================== 增量导入 ====================
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* AI 相关 API - AI 对话、LLM 服务、Agent、Embedding
|
||||
*/
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { ExportProgress } from '../../../src/types/base'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
@@ -237,17 +238,26 @@ export const aiApi = {
|
||||
return ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords)
|
||||
},
|
||||
|
||||
// ==================== 自定义筛选 ====================
|
||||
// ==================== 自定义筛选(支持分页) ====================
|
||||
|
||||
/**
|
||||
* 按条件筛选消息并扩充上下文
|
||||
* 筛选结果消息类型
|
||||
*/
|
||||
// FilterMessage 和 FilterResult 类型定义在下方
|
||||
|
||||
/**
|
||||
* 按条件筛选消息并扩充上下文(支持分页)
|
||||
* @param page 页码(从 1 开始,默认 1)
|
||||
* @param pageSize 每页块数(默认 50)
|
||||
*/
|
||||
filterMessagesWithContext: (
|
||||
sessionId: string,
|
||||
keywords?: string[],
|
||||
timeFilter?: { startTs: number; endTs: number },
|
||||
senderIds?: number[],
|
||||
contextSize?: number
|
||||
contextSize?: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<{
|
||||
blocks: Array<{
|
||||
startTs: number
|
||||
@@ -273,16 +283,36 @@ export const aiApi = {
|
||||
hitMessages: number
|
||||
totalChars: number
|
||||
}
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}> => {
|
||||
return ipcRenderer.invoke('ai:filterMessagesWithContext', sessionId, keywords, timeFilter, senderIds, contextSize)
|
||||
return ipcRenderer.invoke(
|
||||
'ai:filterMessagesWithContext',
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
senderIds,
|
||||
contextSize,
|
||||
page,
|
||||
pageSize
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取多个会话的完整消息
|
||||
* 获取多个会话的完整消息(支持分页)
|
||||
* @param page 页码(从 1 开始,默认 1)
|
||||
* @param pageSize 每页块数(默认 50)
|
||||
*/
|
||||
getMultipleSessionsMessages: (
|
||||
sessionId: string,
|
||||
chatSessionIds: number[]
|
||||
chatSessionIds: number[],
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<{
|
||||
blocks: Array<{
|
||||
startTs: number
|
||||
@@ -308,8 +338,45 @@ export const aiApi = {
|
||||
hitMessages: number
|
||||
totalChars: number
|
||||
}
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
}> => {
|
||||
return ipcRenderer.invoke('ai:getMultipleSessionsMessages', sessionId, chatSessionIds)
|
||||
return ipcRenderer.invoke('ai:getMultipleSessionsMessages', sessionId, chatSessionIds, page, pageSize)
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出筛选结果到文件(后端生成,支持大数据量)
|
||||
*/
|
||||
exportFilterResultToFile: (params: {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
outputDir: string
|
||||
filterMode: 'condition' | 'session'
|
||||
keywords?: string[]
|
||||
timeFilter?: { startTs: number; endTs: number }
|
||||
senderIds?: number[]
|
||||
contextSize?: number
|
||||
chatSessionIds?: number[]
|
||||
}): Promise<{ success: boolean; filePath?: string; error?: string }> => {
|
||||
return ipcRenderer.invoke('ai:exportFilterResultToFile', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 监听导出进度
|
||||
*/
|
||||
onExportProgress: (callback: (progress: ExportProgress) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, progress: ExportProgress) => {
|
||||
callback(progress)
|
||||
}
|
||||
ipcRenderer.on('ai:exportProgress', handler)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('ai:exportProgress', handler)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
43
electron/preload/index.d.ts
vendored
43
electron/preload/index.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { AnalysisSession, MessageType, ImportProgress } from '../../src/types/base'
|
||||
import type { AnalysisSession, MessageType, ImportProgress, ExportProgress } from '../../src/types/base'
|
||||
import type {
|
||||
MemberActivity,
|
||||
MemberNameHistory,
|
||||
@@ -252,6 +252,20 @@ interface FilterResult {
|
||||
}
|
||||
}
|
||||
|
||||
// 分页信息类型
|
||||
interface PaginationInfo {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// 带分页的筛选结果类型
|
||||
interface FilterResultWithPagination extends FilterResult {
|
||||
pagination: PaginationInfo
|
||||
}
|
||||
|
||||
interface AIConversation {
|
||||
id: string
|
||||
sessionId: string
|
||||
@@ -348,15 +362,36 @@ interface AiApi {
|
||||
getMessages: (conversationId: string) => Promise<AIMessage[]>
|
||||
deleteMessage: (messageId: string) => Promise<boolean>
|
||||
showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||
// 自定义筛选
|
||||
// 自定义筛选(支持分页)
|
||||
filterMessagesWithContext: (
|
||||
sessionId: string,
|
||||
keywords?: string[],
|
||||
timeFilter?: TimeFilter,
|
||||
senderIds?: number[],
|
||||
contextSize?: number,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => Promise<FilterResultWithPagination>
|
||||
getMultipleSessionsMessages: (
|
||||
sessionId: string,
|
||||
chatSessionIds: number[],
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) => Promise<FilterResultWithPagination>
|
||||
// 导出筛选结果到文件
|
||||
exportFilterResultToFile: (params: {
|
||||
sessionId: string
|
||||
sessionName: string
|
||||
outputDir: string
|
||||
filterMode: 'condition' | 'session'
|
||||
keywords?: string[]
|
||||
timeFilter?: TimeFilter
|
||||
senderIds?: number[]
|
||||
contextSize?: number
|
||||
) => Promise<FilterResult>
|
||||
getMultipleSessionsMessages: (sessionId: string, chatSessionIds: number[]) => Promise<FilterResult>
|
||||
chatSessionIds?: number[]
|
||||
}) => Promise<{ success: boolean; filePath?: string; error?: string }>
|
||||
// 监听导出进度
|
||||
onExportProgress: (callback: (progress: ExportProgress) => void) => () => void
|
||||
}
|
||||
|
||||
// LLM 相关类型
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
* 支持两种互斥的筛选模式:
|
||||
* 1. 条件筛选:按关键词、时间、发送者筛选,并自动扩展上下文
|
||||
* 2. 会话筛选:直接选择已有的会话(对话段落)
|
||||
*
|
||||
* 支持分页加载,避免大数据量时内存溢出
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, toRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from '@nuxt/ui/runtime/composables/useToast.js'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import ConditionPanel from './ConditionPanel.vue'
|
||||
import SessionPanel from './SessionPanel.vue'
|
||||
@@ -18,6 +21,7 @@ import FilterHistory from './FilterHistory.vue'
|
||||
import LocalAnalysisModal from './LocalAnalysisModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const sessionStore = useSessionStore()
|
||||
|
||||
// 筛选模式:'condition' | 'session'
|
||||
@@ -55,7 +59,16 @@ interface FilterMessage {
|
||||
isHit: boolean
|
||||
}
|
||||
|
||||
// 筛选结果
|
||||
// 分页信息类型
|
||||
interface PaginationInfo {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// 筛选结果(带分页)
|
||||
const filterResult = ref<{
|
||||
blocks: Array<{
|
||||
startTs: number
|
||||
@@ -68,13 +81,18 @@ const filterResult = ref<{
|
||||
hitMessages: number
|
||||
totalChars: number
|
||||
}
|
||||
pagination: PaginationInfo
|
||||
} | null>(null)
|
||||
|
||||
// 加载状态
|
||||
const isFiltering = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const showHistory = ref(false)
|
||||
const showAnalysisModal = ref(false)
|
||||
|
||||
// 每页块数
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
// 估算 Token 数
|
||||
// 中文:1 字符 ≈ 1.5 token(因为中文分词后每个字符可能产生 1-2 个 token)
|
||||
// 考虑到消息格式(时间、发送人等),使用 1.5 作为估算系数
|
||||
@@ -109,7 +127,7 @@ const canExecuteFilter = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 执行筛选
|
||||
// 执行筛选(首次加载)
|
||||
async function executeFilter() {
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
if (!sessionId) return
|
||||
@@ -133,14 +151,16 @@ async function executeFilter() {
|
||||
keywords,
|
||||
timeFilter,
|
||||
senderIds,
|
||||
contextSize
|
||||
contextSize,
|
||||
1, // 第一页
|
||||
PAGE_SIZE
|
||||
)
|
||||
filterResult.value = result
|
||||
} else {
|
||||
// 会话筛选模式
|
||||
if (selectedSessionIds.value.length === 0) return
|
||||
const sessionIds = [...toRaw(selectedSessionIds.value)]
|
||||
const result = await window.aiApi.getMultipleSessionsMessages(sessionId, sessionIds)
|
||||
const result = await window.aiApi.getMultipleSessionsMessages(sessionId, sessionIds, 1, PAGE_SIZE)
|
||||
filterResult.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -150,69 +170,159 @@ async function executeFilter() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出投喂包(Markdown 格式)
|
||||
// 加载更多块
|
||||
async function loadMoreBlocks() {
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
if (!sessionId || !filterResult.value || !filterResult.value.pagination.hasMore || isLoadingMore.value) return
|
||||
|
||||
isLoadingMore.value = true
|
||||
const nextPage = filterResult.value.pagination.page + 1
|
||||
|
||||
try {
|
||||
let result
|
||||
if (filterMode.value === 'condition') {
|
||||
const rawFilter = toRaw(conditionFilter.value)
|
||||
const keywords = rawFilter.keywords.length > 0 ? [...rawFilter.keywords] : undefined
|
||||
const timeFilter = rawFilter.timeRange
|
||||
? { startTs: rawFilter.timeRange.start, endTs: rawFilter.timeRange.end }
|
||||
: undefined
|
||||
const senderIds = rawFilter.senderIds.length > 0 ? [...rawFilter.senderIds] : undefined
|
||||
const contextSize = rawFilter.contextSize
|
||||
|
||||
result = await window.aiApi.filterMessagesWithContext(
|
||||
sessionId,
|
||||
keywords,
|
||||
timeFilter,
|
||||
senderIds,
|
||||
contextSize,
|
||||
nextPage,
|
||||
PAGE_SIZE
|
||||
)
|
||||
} else {
|
||||
const sessionIds = [...toRaw(selectedSessionIds.value)]
|
||||
result = await window.aiApi.getMultipleSessionsMessages(sessionId, sessionIds, nextPage, PAGE_SIZE)
|
||||
}
|
||||
|
||||
// 合并新加载的块到现有结果
|
||||
if (result && result.blocks.length > 0) {
|
||||
filterResult.value = {
|
||||
blocks: [...filterResult.value.blocks, ...result.blocks],
|
||||
stats: filterResult.value.stats, // 统计信息保持不变(第一页已获取)
|
||||
pagination: result.pagination,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多失败:', error)
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出状态
|
||||
const isExporting = ref(false)
|
||||
const exportProgress = ref<{
|
||||
percentage: number
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
// 监听导出进度
|
||||
let unsubscribeExportProgress: (() => void) | null = null
|
||||
|
||||
function startExportProgressListener() {
|
||||
unsubscribeExportProgress = window.aiApi.onExportProgress((progress) => {
|
||||
exportProgress.value = {
|
||||
percentage: progress.percentage,
|
||||
message: progress.message,
|
||||
}
|
||||
// 如果完成或出错,不再需要监听
|
||||
if (progress.stage === 'done' || progress.stage === 'error') {
|
||||
exportProgress.value = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stopExportProgressListener() {
|
||||
if (unsubscribeExportProgress) {
|
||||
unsubscribeExportProgress()
|
||||
unsubscribeExportProgress = null
|
||||
}
|
||||
exportProgress.value = null
|
||||
}
|
||||
|
||||
// 导出投喂包(后端生成 Markdown 文件,支持大数据量)
|
||||
async function exportFeedPack() {
|
||||
if (!filterResult.value || filterResult.value.blocks.length === 0) return
|
||||
|
||||
const sessionId = sessionStore.currentSessionId
|
||||
if (!sessionId) return
|
||||
|
||||
const sessionInfo = sessionStore.currentSession
|
||||
const sessionName = sessionInfo?.name || '未知会话'
|
||||
|
||||
// 构建 Markdown 内容
|
||||
let markdown = `# ${sessionName} - 聊天记录筛选结果\n\n`
|
||||
markdown += `> 导出时间: ${new Date().toLocaleString()}\n\n`
|
||||
// 让用户选择保存目录
|
||||
const dialogResult = await window.api.dialog.showOpenDialog({
|
||||
title: '选择保存目录',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
if (dialogResult.canceled || !dialogResult.filePaths[0]) return
|
||||
const outputDir = dialogResult.filePaths[0]
|
||||
|
||||
// 筛选条件摘要
|
||||
markdown += `## 筛选条件\n\n`
|
||||
if (filterMode.value === 'condition') {
|
||||
if (conditionFilter.value.keywords.length > 0) {
|
||||
markdown += `- 关键词: ${conditionFilter.value.keywords.join(', ')}\n`
|
||||
isExporting.value = true
|
||||
exportProgress.value = { percentage: 0, message: t('analysis.filter.exportPreparing') }
|
||||
|
||||
// 开始监听进度
|
||||
startExportProgressListener()
|
||||
|
||||
try {
|
||||
// 构建导出参数
|
||||
const rawFilter = toRaw(conditionFilter.value)
|
||||
const exportParams = {
|
||||
sessionId,
|
||||
sessionName,
|
||||
outputDir,
|
||||
filterMode: filterMode.value,
|
||||
keywords: rawFilter.keywords.length > 0 ? [...rawFilter.keywords] : undefined,
|
||||
timeFilter: rawFilter.timeRange
|
||||
? { startTs: rawFilter.timeRange.start, endTs: rawFilter.timeRange.end }
|
||||
: undefined,
|
||||
senderIds: rawFilter.senderIds.length > 0 ? [...rawFilter.senderIds] : undefined,
|
||||
contextSize: rawFilter.contextSize,
|
||||
chatSessionIds:
|
||||
filterMode.value === 'session' ? [...toRaw(selectedSessionIds.value)] : undefined,
|
||||
}
|
||||
if (conditionFilter.value.timeRange) {
|
||||
const start = new Date(conditionFilter.value.timeRange.start * 1000).toLocaleString()
|
||||
const end = new Date(conditionFilter.value.timeRange.end * 1000).toLocaleString()
|
||||
markdown += `- 时间范围: ${start} ~ ${end}\n`
|
||||
|
||||
// 调用后端导出
|
||||
const exportResult = await window.aiApi.exportFilterResultToFile(exportParams)
|
||||
|
||||
if (exportResult.success && exportResult.filePath) {
|
||||
// 导出成功
|
||||
toast.add({
|
||||
title: t('analysis.filter.exportSuccess'),
|
||||
description: exportResult.filePath,
|
||||
color: 'green',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
})
|
||||
} else {
|
||||
// 导出失败
|
||||
toast.add({
|
||||
title: t('analysis.filter.exportFailed'),
|
||||
description: exportResult.error || t('common.error.unknown'),
|
||||
color: 'red',
|
||||
icon: 'i-heroicons-x-circle',
|
||||
})
|
||||
}
|
||||
markdown += `- 上下文扩展: ±${conditionFilter.value.contextSize} 条消息\n`
|
||||
} else {
|
||||
markdown += `- 模式: 会话筛选\n`
|
||||
markdown += `- 选中会话数: ${selectedSessionIds.value.length}\n`
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
toast.add({
|
||||
title: t('analysis.filter.exportFailed'),
|
||||
description: String(error),
|
||||
color: 'red',
|
||||
icon: 'i-heroicons-x-circle',
|
||||
})
|
||||
} finally {
|
||||
stopExportProgressListener()
|
||||
isExporting.value = false
|
||||
}
|
||||
|
||||
// 统计信息
|
||||
markdown += `\n## 统计信息\n\n`
|
||||
markdown += `- 消息总数: ${filterResult.value.stats.totalMessages}\n`
|
||||
markdown += `- 命中消息: ${filterResult.value.stats.hitMessages}\n`
|
||||
markdown += `- 字符数: ${filterResult.value.stats.totalChars}\n`
|
||||
markdown += `- 预估 Token: ${estimatedTokens.value}\n`
|
||||
|
||||
// 对话内容
|
||||
markdown += `\n## 对话内容\n\n`
|
||||
|
||||
for (let i = 0; i < filterResult.value.blocks.length; i++) {
|
||||
const block = filterResult.value.blocks[i]
|
||||
const startTime = new Date(block.startTs * 1000).toLocaleString()
|
||||
const endTime = new Date(block.endTs * 1000).toLocaleString()
|
||||
|
||||
markdown += `### 对话块 ${i + 1} (${startTime} ~ ${endTime})\n\n`
|
||||
|
||||
for (const msg of block.messages) {
|
||||
const time = new Date(msg.timestamp * 1000).toLocaleTimeString()
|
||||
const hitMark = msg.isHit ? ' ⭐' : ''
|
||||
const content = msg.content || '[非文本消息]'
|
||||
markdown += `${time} ${msg.senderName}${hitMark}: ${content}\n`
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并下载文件
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${sessionName}_筛选结果_${Date.now()}.md`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 打开本地 AI 分析
|
||||
@@ -313,21 +423,45 @@ function loadHistoryCondition(condition: {
|
||||
<PreviewPanel
|
||||
:result="filterResult"
|
||||
:is-loading="isFiltering"
|
||||
:is-loading-more="isLoadingMore"
|
||||
:estimated-tokens="estimatedTokens"
|
||||
:token-status="tokenStatus"
|
||||
@load-more="loadMoreBlocks"
|
||||
/>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div
|
||||
v-if="filterResult && filterResult.blocks.length > 0"
|
||||
class="flex-none flex items-center justify-end gap-3 px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
|
||||
class="flex-none flex flex-col gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<UButton variant="outline" icon="i-heroicons-document-arrow-down" @click="exportFeedPack">
|
||||
{{ t('analysis.filter.export') }}
|
||||
</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-sparkles" @click="openLocalAnalysis">
|
||||
{{ t('analysis.filter.localAnalysis') }}
|
||||
</UButton>
|
||||
<!-- 导出进度条 -->
|
||||
<div v-if="isExporting && exportProgress" class="w-full">
|
||||
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{{ exportProgress.message }}</span>
|
||||
<span>{{ exportProgress.percentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary-500 transition-all duration-300"
|
||||
:style="{ width: `${exportProgress.percentage}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<UButton
|
||||
variant="outline"
|
||||
icon="i-heroicons-document-arrow-down"
|
||||
:loading="isExporting"
|
||||
:disabled="isExporting"
|
||||
@click="exportFeedPack"
|
||||
>
|
||||
{{ t('analysis.filter.export') }}
|
||||
</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-sparkles" @click="openLocalAnalysis">
|
||||
{{ t('analysis.filter.localAnalysis') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,15 @@ const props = defineProps<{
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
// 数据量阈值(超过此数量时提示数据量过大)
|
||||
const DATA_TOO_LARGE_THRESHOLD = 5000
|
||||
|
||||
// 检查数据量是否过大
|
||||
const isDataTooLarge = computed(() => {
|
||||
if (!props.filterResult) return false
|
||||
return props.filterResult.stats.totalMessages > DATA_TOO_LARGE_THRESHOLD
|
||||
})
|
||||
|
||||
// 分析模式:'preset' | 'custom'
|
||||
const analysisMode = ref<'preset' | 'custom'>('preset')
|
||||
|
||||
@@ -224,6 +233,24 @@ watch(open, (val) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据量过大警告 -->
|
||||
<div
|
||||
v-if="isDataTooLarge"
|
||||
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-red-700 dark:text-red-400">
|
||||
{{ t('analysis.filter.dataTooLarge') }}
|
||||
</p>
|
||||
<p class="text-red-600 dark:text-red-500 mt-1">
|
||||
{{ t('analysis.filter.dataTooLargeThreshold', { count: DATA_TOO_LARGE_THRESHOLD }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析模式切换 -->
|
||||
<div class="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg w-fit">
|
||||
<button
|
||||
@@ -295,7 +322,7 @@ watch(open, (val) => {
|
||||
<UButton
|
||||
color="primary"
|
||||
:loading="isAnalyzing"
|
||||
:disabled="isAnalyzing || (analysisMode === 'custom' && !customPrompt.trim())"
|
||||
:disabled="isAnalyzing || isDataTooLarge || (analysisMode === 'custom' && !customPrompt.trim())"
|
||||
@click="executeAnalysis"
|
||||
>
|
||||
<UIcon name="i-heroicons-sparkles" class="w-4 h-4 mr-1" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 预览面板
|
||||
* 左右结构:左侧对话块列表(虚拟滚动),右侧消息内容(复用 MessageList)
|
||||
* 支持连续滚动:滚动到底部时自动加载下一个对话块
|
||||
* 支持分页加载:滚动到左侧列表底部时触发加载更多
|
||||
*/
|
||||
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
@@ -14,6 +15,15 @@ import type { ChatRecordMessage } from '@/types/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 分页信息类型
|
||||
interface PaginationInfo {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalBlocks: number
|
||||
totalHits: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
result: {
|
||||
@@ -41,12 +51,19 @@ const props = defineProps<{
|
||||
hitMessages: number
|
||||
totalChars: number
|
||||
}
|
||||
pagination?: PaginationInfo
|
||||
} | null
|
||||
isLoading: boolean
|
||||
isLoadingMore?: boolean
|
||||
estimatedTokens: number
|
||||
tokenStatus: 'green' | 'yellow' | 'red'
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'load-more'): void
|
||||
}>()
|
||||
|
||||
// 当前选中的对话块索引(用于左侧高亮和右侧显示)
|
||||
const selectedBlockIndex = ref(0)
|
||||
|
||||
@@ -231,6 +248,19 @@ watch(pendingScrollToMessageId, async (messageId) => {
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// 处理左侧块列表滚动,接近底部时自动加载更多
|
||||
function handleBlockListScroll(event: Event) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target || !props.result?.pagination?.hasMore || props.isLoadingMore) return
|
||||
|
||||
// 距离底部 100px 时触发加载
|
||||
const threshold = 100
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
if (scrollHeight - scrollTop - clientHeight < threshold) {
|
||||
emit('load-more')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -244,7 +274,12 @@ watch(pendingScrollToMessageId, async (messageId) => {
|
||||
<div class="flex items-center gap-6 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('analysis.filter.stats.blocks') }}:
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ result.blocks.length }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ result.blocks.length }}
|
||||
<template v-if="result.pagination && result.pagination.totalBlocks > result.blocks.length">
|
||||
/ {{ result.pagination.totalBlocks }}
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('analysis.filter.stats.messages') }}:
|
||||
@@ -252,7 +287,9 @@ watch(pendingScrollToMessageId, async (messageId) => {
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('analysis.filter.stats.hits') }}:
|
||||
<span class="font-medium text-primary-500">{{ result.stats.hitMessages }}</span>
|
||||
<span class="font-medium text-primary-500">
|
||||
{{ result.pagination?.totalHits ?? result.stats.hitMessages }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('analysis.filter.stats.chars') }}:
|
||||
@@ -326,11 +363,15 @@ watch(pendingScrollToMessageId, async (messageId) => {
|
||||
>
|
||||
<div class="flex-none px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('analysis.filter.stats.blocks') }} ({{ result.blocks.length }})
|
||||
{{ t('analysis.filter.stats.blocks') }}
|
||||
({{ result.blocks.length
|
||||
}}<template v-if="result.pagination && result.pagination.totalBlocks > result.blocks.length"
|
||||
>/{{ result.pagination.totalBlocks }}</template
|
||||
>)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref="blockListRef" class="flex-1 overflow-y-auto">
|
||||
<div ref="blockListRef" class="flex-1 overflow-y-auto" @scroll="handleBlockListScroll">
|
||||
<div :style="{ height: `${blockVirtualizer.getTotalSize()}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="virtualItem in virtualBlocks"
|
||||
@@ -381,6 +422,28 @@ watch(pendingScrollToMessageId, async (messageId) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<div
|
||||
v-if="result.pagination?.hasMore"
|
||||
class="py-3 text-center text-sm text-gray-500 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<template v-if="isLoadingMore">
|
||||
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin inline mr-1" />
|
||||
{{ t('common.loading') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="text-primary-500 hover:text-primary-600" @click="emit('load-more')">
|
||||
{{ t('analysis.filter.loadMore') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="result.pagination && result.blocks.length >= result.pagination.totalBlocks"
|
||||
class="py-3 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{{ t('analysis.filter.allLoaded') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
"editablePromptLabel": "Prompt (editable)",
|
||||
"analyzing": "Analyzing...",
|
||||
"startAnalysis": "Start Analysis",
|
||||
"analysisResult": "Analysis Result"
|
||||
"analysisResult": "Analysis Result",
|
||||
"loadMore": "Load More",
|
||||
"allLoaded": "All loaded",
|
||||
"dataTooLarge": "Data too large for AI analysis. Please narrow your filter.",
|
||||
"dataTooLargeThreshold": "Current data exceeds {count} messages",
|
||||
"exportSuccess": "Export successful",
|
||||
"exportFailed": "Export failed",
|
||||
"exportPreparing": "Preparing export..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"noData": "No data",
|
||||
"error": {
|
||||
"general": "Operation failed, please try again",
|
||||
"network": "Network error"
|
||||
"network": "Network error",
|
||||
"unknown": "Unknown error"
|
||||
},
|
||||
"attachment": {
|
||||
"image": "Image",
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
"editablePromptLabel": "提示词(可临时修改)",
|
||||
"analyzing": "分析中...",
|
||||
"startAnalysis": "开始分析",
|
||||
"analysisResult": "分析结果"
|
||||
"analysisResult": "分析结果",
|
||||
"loadMore": "加载更多",
|
||||
"allLoaded": "已加载全部",
|
||||
"dataTooLarge": "数据量过大,无法进行 AI 分析。请缩小筛选范围。",
|
||||
"dataTooLargeThreshold": "当前数据超过 {count} 条消息",
|
||||
"exportSuccess": "导出成功",
|
||||
"exportFailed": "导出失败",
|
||||
"exportPreparing": "正在准备导出..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"noData": "暂无数据",
|
||||
"error": {
|
||||
"general": "操作失败,请重试",
|
||||
"network": "网络错误"
|
||||
"network": "网络错误",
|
||||
"unknown": "未知错误"
|
||||
},
|
||||
"attachment": {
|
||||
"image": "图片",
|
||||
|
||||
@@ -250,6 +250,17 @@ export interface ImportProgress {
|
||||
messagesProcessed?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出进度
|
||||
*/
|
||||
export interface ExportProgress {
|
||||
stage: 'preparing' | 'exporting' | 'done' | 'error'
|
||||
currentBlock: number
|
||||
totalBlocks: number
|
||||
percentage: number // 0-100
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入结果
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user