fix: 修复windows用户自定义筛选时消息量过大导致软件崩溃的BUG

This commit is contained in:
digua
2026-02-02 23:51:12 +08:00
committed by digua
parent 38738eb090
commit 878507ba8a
13 changed files with 588 additions and 109 deletions

View File

@@ -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) }
}
}
)
}

View File

@@ -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),
}
// 处理消息

View File

@@ -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)
}
// ==================== 增量导入 ====================

View File

@@ -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)
}
},
/**

View File

@@ -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 相关类型

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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..."
}
}

View File

@@ -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",

View File

@@ -99,6 +99,13 @@
"editablePromptLabel": "提示词(可临时修改)",
"analyzing": "分析中...",
"startAnalysis": "开始分析",
"analysisResult": "分析结果"
"analysisResult": "分析结果",
"loadMore": "加载更多",
"allLoaded": "已加载全部",
"dataTooLarge": "数据量过大,无法进行 AI 分析。请缩小筛选范围。",
"dataTooLargeThreshold": "当前数据超过 {count} 条消息",
"exportSuccess": "导出成功",
"exportFailed": "导出失败",
"exportPreparing": "正在准备导出..."
}
}

View File

@@ -16,7 +16,8 @@
"noData": "暂无数据",
"error": {
"general": "操作失败,请重试",
"network": "网络错误"
"network": "网络错误",
"unknown": "未知错误"
},
"attachment": {
"image": "图片",

View File

@@ -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
}
/**
* 导入结果
*/