feat: 聊天会话支持摘要功能

This commit is contained in:
digua
2026-01-25 18:54:27 +08:00
parent f14c18d68f
commit ec2f91965e
7 changed files with 1373 additions and 13 deletions

View File

@@ -0,0 +1,456 @@
/**
* 会话摘要生成服务
*
* 利用 LLM 为会话生成摘要
* - 智能预处理:过滤无意义内容(纯表情、单字回复等)
* - 根据消息数量智能调整摘要长度
* - 超长会话采用 Map-Reduce 策略
*/
import Database from 'better-sqlite3'
import { chat } from '../llm'
import { getDbPath, openDatabase } from '../../database/core'
import { aiLogger } from '../logger'
/** 最小消息数阈值(少于此数量不生成摘要) */
const MIN_MESSAGE_COUNT = 3
/** 单次 LLM 调用的最大内容字符数(约 2000 tokens留安全余量 */
const MAX_CONTENT_PER_CALL = 8000
/** 需要分段处理的阈值 */
const SEGMENT_THRESHOLD = 8000
// ==================== 数据库操作函数(独立于 Worker ====================
interface SessionMessagesResult {
messageCount: number
messages: Array<{
senderName: string
content: string | null
}>
}
/**
* 获取会话消息(主进程版本,使用 database/core
*/
function getSessionMessagesForSummary(
dbSessionId: string,
chatSessionId: number,
limit: number = 500
): SessionMessagesResult | null {
const db = openDatabase(dbSessionId, true)
if (!db) {
aiLogger.error('Summary', `数据库打开失败: ${dbSessionId}`)
return null
}
try {
// 获取会话消息
const messagesSql = `
SELECT
COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName,
m.content
FROM message_context mc
JOIN message m ON m.id = mc.message_id
JOIN member mb ON mb.id = m.sender_id
WHERE mc.session_id = ?
ORDER BY m.ts ASC
LIMIT ?
`
const messages = db.prepare(messagesSql).all(chatSessionId, limit) as Array<{
senderName: string
content: string | null
}>
return {
messageCount: messages.length,
messages,
}
} catch (error) {
aiLogger.error('Summary', `获取会话消息失败: ${error}`)
return null
}
}
/**
* 保存会话摘要(主进程版本)
*/
function saveSessionSummaryToDb(dbSessionId: string, chatSessionId: number, summary: string): void {
const dbPath = getDbPath(dbSessionId)
const db = new Database(dbPath)
try {
db.prepare('UPDATE chat_session SET summary = ? WHERE id = ?').run(summary, chatSessionId)
} finally {
db.close()
}
}
/**
* 获取会话摘要(主进程版本)
*/
function getSessionSummaryFromDb(dbSessionId: string, chatSessionId: number): string | null {
const db = openDatabase(dbSessionId, true)
if (!db) {
return null
}
try {
const result = db.prepare('SELECT summary FROM chat_session WHERE id = ?').get(chatSessionId) as
| { summary: string | null }
| undefined
return result?.summary || null
} catch {
return null
}
}
/**
* 根据消息数量计算摘要长度限制
* - 3-10 条消息50 字
* - 11-30 条消息80 字
* - 31-100 条消息120 字
* - 100+ 条消息200 字
*/
function getSummaryLengthLimit(messageCount: number): number {
if (messageCount <= 10) return 50
if (messageCount <= 30) return 80
if (messageCount <= 100) return 120
return 200
}
/**
* 判断消息是否有意义(用于过滤)
*/
function isValidMessage(content: string): boolean {
const trimmed = content.trim()
// 过滤空内容
if (!trimmed) return false
// 过滤单字/双字无意义回复
if (trimmed.length <= 2) {
// 允许一些有意义的短词
const meaningfulShort = ['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意']
if (!meaningfulShort.includes(trimmed)) return false
}
// 过滤纯表情消息
const emojiOnlyPattern = /^[\p{Emoji}\s[\]()]+$/u
if (emojiOnlyPattern.test(trimmed)) return false
// 过滤占位符文本
const placeholders = ['[图片]', '[语音]', '[视频]', '[文件]', '[表情]', '[动画表情]', '[位置]', '[名片]', '[红包]', '[转账]', '[撤回消息]']
if (placeholders.some((p) => trimmed === p)) return false
// 过滤系统消息(入群、退群等)
const systemPatterns = [
/^.*邀请.*加入了群聊$/,
/^.*退出了群聊$/,
/^.*撤回了一条消息$/,
/^你撤回了一条消息$/,
]
if (systemPatterns.some((p) => p.test(trimmed))) return false
return true
}
/**
* 预处理消息:过滤无意义内容
*/
function preprocessMessages(
messages: Array<{ senderName: string; content: string | null }>
): Array<{ senderName: string; content: string }> {
return messages
.filter((m) => m.content && isValidMessage(m.content))
.map((m) => ({ senderName: m.senderName, content: m.content!.trim() }))
}
/**
* 格式化消息为文本
*/
function formatMessages(messages: Array<{ senderName: string; content: string }>): string {
return messages.map((m) => `${m.senderName}: ${m.content}`).join('\n')
}
/**
* 将消息分成多个段落
*/
function splitIntoSegments(
messages: Array<{ senderName: string; content: string }>,
maxCharsPerSegment: number
): Array<Array<{ senderName: string; content: string }>> {
const segments: Array<Array<{ senderName: string; content: string }>> = []
let currentSegment: Array<{ senderName: string; content: string }> = []
let currentLength = 0
for (const msg of messages) {
const msgLength = msg.senderName.length + msg.content.length + 3 // "name: content\n"
if (currentLength + msgLength > maxCharsPerSegment && currentSegment.length > 0) {
segments.push(currentSegment)
currentSegment = []
currentLength = 0
}
currentSegment.push(msg)
currentLength += msgLength
}
if (currentSegment.length > 0) {
segments.push(currentSegment)
}
return segments
}
/**
* 生成摘要的 Prompt
*/
function buildSummaryPrompt(content: string, lengthLimit: number, locale: string): string {
if (locale === 'zh-CN') {
return `请用简洁的语言(${lengthLimit}字以内)总结以下对话的主要内容或话题。只输出摘要内容,不要添加任何前缀、解释或引号。
${content}`
}
return `Summarize the following conversation concisely (max ${lengthLimit} characters). Output only the summary, no prefix, explanation, or quotes.
${content}`
}
/**
* 生成子摘要的 Prompt
*/
function buildSubSummaryPrompt(content: string, locale: string): string {
if (locale === 'zh-CN') {
return `请用一句话不超过50字概括以下对话片段的主要内容。只输出摘要内容不要添加任何前缀、解释或引号。
${content}`
}
return `Summarize this conversation segment in one sentence (max 50 characters). Output only the summary, no prefix or quotes.
${content}`
}
/**
* 合并子摘要的 Prompt
*/
function buildMergePrompt(subSummaries: string[], lengthLimit: number, locale: string): string {
const summaryList = subSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n')
if (locale === 'zh-CN') {
return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。只输出摘要内容,不要添加任何前缀、解释或引号。
${summaryList}`
}
return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Output only the summary, no prefix or quotes.
${summaryList}`
}
/**
* 生成会话摘要
*
* @param dbSessionId 数据库会话ID用于访问数据库
* @param chatSessionId 会话索引中的会话ID
* @param locale 语言设置
* @param forceRegenerate 是否强制重新生成(忽略缓存)
* @returns 摘要内容或错误
*/
export async function generateSessionSummary(
dbSessionId: string,
chatSessionId: number,
locale: string = 'zh-CN',
forceRegenerate: boolean = false
): Promise<{ success: boolean; summary?: string; error?: string }> {
try {
// 1. 检查是否已有摘要(除非强制重新生成)
if (!forceRegenerate) {
const existing = getSessionSummaryFromDb(dbSessionId, chatSessionId)
if (existing) {
return { success: true, summary: existing }
}
}
// 2. 获取会话消息
const sessionData = getSessionMessagesForSummary(dbSessionId, chatSessionId)
if (!sessionData) {
return { success: false, error: '会话不存在或数据库打开失败' }
}
// 3. 检查消息数量
if (sessionData.messageCount < MIN_MESSAGE_COUNT) {
return {
success: false,
error:
locale === 'zh-CN'
? `消息数量少于${MIN_MESSAGE_COUNT}条,无需生成摘要`
: `Message count less than ${MIN_MESSAGE_COUNT}, no need to generate summary`,
}
}
// 4. 预处理:过滤无意义消息
const validMessages = preprocessMessages(sessionData.messages)
if (validMessages.length < MIN_MESSAGE_COUNT) {
return {
success: false,
error:
locale === 'zh-CN'
? `有效消息数量少于${MIN_MESSAGE_COUNT}条,无需生成摘要`
: `Valid message count less than ${MIN_MESSAGE_COUNT}, no need to generate summary`,
}
}
// 5. 计算摘要长度限制
const lengthLimit = getSummaryLengthLimit(validMessages.length)
// 6. 格式化内容
const content = formatMessages(validMessages)
aiLogger.info(
'Summary',
`生成会话摘要: sessionId=${chatSessionId}, 原始消息=${sessionData.messageCount}, 有效消息=${validMessages.length}, 内容长度=${content.length}`
)
let summary: string
// 7. 根据内容长度决定处理策略
if (content.length <= SEGMENT_THRESHOLD) {
// 短会话:直接生成摘要
summary = await generateDirectSummary(content, lengthLimit, locale)
} else {
// 长会话Map-Reduce 策略
summary = await generateMapReduceSummary(validMessages, lengthLimit, locale)
}
// 8. 后处理:移除引号
if ((summary.startsWith('"') && summary.endsWith('"')) || (summary.startsWith('「') && summary.endsWith('」'))) {
summary = summary.slice(1, -1)
}
// 如果摘要超过限制的 1.5 倍,进行截断
const hardLimit = Math.floor(lengthLimit * 1.5)
if (summary.length > hardLimit) {
summary = summary.slice(0, hardLimit - 3) + '...'
}
// 9. 保存到数据库
saveSessionSummaryToDb(dbSessionId, chatSessionId, summary)
aiLogger.info('Summary', `摘要生成成功: "${summary.slice(0, 50)}..."`)
return { success: true, summary }
} catch (error) {
aiLogger.error('Summary', '摘要生成失败', error)
return {
success: false,
error: error instanceof Error ? error.message : String(error),
}
}
}
/**
* 直接生成摘要(适用于短会话)
*/
async function generateDirectSummary(content: string, lengthLimit: number, locale: string): Promise<string> {
const response = await chat(
[
{ role: 'system', content: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。' },
{ role: 'user', content: buildSummaryPrompt(content, lengthLimit, locale) },
],
{
temperature: 0.3,
maxTokens: 300,
}
)
return response.content.trim()
}
/**
* Map-Reduce 策略生成摘要(适用于长会话)
*/
async function generateMapReduceSummary(
messages: Array<{ senderName: string; content: string }>,
lengthLimit: number,
locale: string
): Promise<string> {
// 1. Map分段生成子摘要
const segments = splitIntoSegments(messages, MAX_CONTENT_PER_CALL)
aiLogger.info('Summary', `长会话分段处理: ${segments.length} 个段落`)
const subSummaries: string[] = []
for (let i = 0; i < segments.length; i++) {
const segmentContent = formatMessages(segments[i])
const response = await chat(
[
{ role: 'system', content: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。' },
{ role: 'user', content: buildSubSummaryPrompt(segmentContent, locale) },
],
{
temperature: 0.3,
maxTokens: 100,
}
)
subSummaries.push(response.content.trim())
}
// 2. Reduce合并子摘要
if (subSummaries.length === 1) {
return subSummaries[0]
}
const mergeResponse = await chat(
[
{ role: 'system', content: '你是一个对话摘要专家,擅长将多个摘要合并成一个连贯的总结。' },
{ role: 'user', content: buildMergePrompt(subSummaries, lengthLimit, locale) },
],
{
temperature: 0.3,
maxTokens: 300,
}
)
return mergeResponse.content.trim()
}
/**
* 批量生成会话摘要
*
* @param dbSessionId 数据库会话ID
* @param chatSessionIds 会话ID列表
* @param locale 语言设置
* @param onProgress 进度回调
* @returns 生成结果
*/
export async function generateSessionSummaries(
dbSessionId: string,
chatSessionIds: number[],
locale: string = 'zh-CN',
onProgress?: (current: number, total: number) => void
): Promise<{ success: number; failed: number; skipped: number }> {
let success = 0
let failed = 0
let skipped = 0
for (let i = 0; i < chatSessionIds.length; i++) {
const chatSessionId = chatSessionIds[i]
const result = await generateSessionSummary(dbSessionId, chatSessionId, locale, false)
if (result.success) {
success++
} else if (result.error?.includes('少于') || result.error?.includes('less than')) {
skipped++
} else {
failed++
}
if (onProgress) {
onProgress(i + 1, chatSessionIds.length)
}
}
return { success, failed, skipped }
}

View File

@@ -746,6 +746,153 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
})
// ==================== 会话摘要 ====================
/**
* 生成单个会话摘要
*/
ipcMain.handle(
'session:generateSummary',
async (_, dbSessionId: string, chatSessionId: number, locale?: string, forceRegenerate?: boolean) => {
console.log('[IPC] session:generateSummary 收到请求:', { dbSessionId, chatSessionId, locale, forceRegenerate })
try {
const { generateSessionSummary } = await import('../ai/summary')
const result = await generateSessionSummary(dbSessionId, chatSessionId, locale || 'zh-CN', forceRegenerate || false)
console.log('[IPC] session:generateSummary 返回:', result)
return result
} catch (error) {
console.error('[IPC] 生成会话摘要失败:', error)
return { success: false, error: String(error) }
}
}
)
/**
* 批量生成会话摘要
*/
ipcMain.handle(
'session:generateSummaries',
async (_, dbSessionId: string, chatSessionIds: number[], locale?: string) => {
try {
const { generateSessionSummaries } = await import('../ai/summary')
return await generateSessionSummaries(dbSessionId, chatSessionIds, locale || 'zh-CN')
} catch (error) {
console.error('批量生成会话摘要失败:', error)
return { success: 0, failed: chatSessionIds.length, skipped: 0 }
}
}
)
/**
* 根据时间范围查询会话列表
*/
ipcMain.handle(
'session:getByTimeRange',
async (_, dbSessionId: string, startTs: number, endTs: number) => {
console.log('[session:getByTimeRange] 查询参数:', { dbSessionId, startTs, endTs })
console.log('[session:getByTimeRange] 时间范围:', {
start: new Date(startTs * 1000).toISOString(),
end: new Date(endTs * 1000).toISOString(),
})
try {
const { openDatabase } = await import('../database/core')
const db = openDatabase(dbSessionId, true)
if (!db) {
console.log('[session:getByTimeRange] 数据库打开失败')
return []
}
// 先查询总数和时间范围
const stats = db.prepare('SELECT COUNT(*) as count, MIN(start_ts) as minTs, MAX(start_ts) as maxTs FROM chat_session').get() as { count: number; minTs: number; maxTs: number }
console.log('[session:getByTimeRange] 数据库会话统计:', {
count: stats.count,
minTs: stats.minTs ? new Date(stats.minTs * 1000).toISOString() : null,
maxTs: stats.maxTs ? new Date(stats.maxTs * 1000).toISOString() : null,
})
const sessions = db
.prepare(
`
SELECT
id,
start_ts as startTs,
end_ts as endTs,
message_count as messageCount,
summary
FROM chat_session
WHERE start_ts >= ? AND start_ts <= ?
ORDER BY start_ts DESC
`
)
.all(startTs, endTs) as Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
console.log('[session:getByTimeRange] 查询结果数量:', sessions.length)
return sessions
} catch (error) {
console.error('查询时间范围会话失败:', error)
return []
}
}
)
/**
* 获取最近 N 条会话
*/
ipcMain.handle('session:getRecent', async (_, dbSessionId: string, limit: number) => {
console.log('[session:getRecent] 查询参数:', { dbSessionId, limit })
try {
const { openDatabase } = await import('../database/core')
const db = openDatabase(dbSessionId, true)
if (!db) {
console.log('[session:getRecent] 数据库打开失败')
return []
}
// 先检查表是否存在
const tableInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_session'").get()
console.log('[session:getRecent] chat_session 表:', tableInfo ? '存在' : '不存在')
if (!tableInfo) {
return []
}
const sessions = db
.prepare(
`
SELECT
id,
start_ts as startTs,
end_ts as endTs,
message_count as messageCount,
summary
FROM chat_session
ORDER BY start_ts DESC
LIMIT ?
`
)
.all(limit) as Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
console.log('[session:getRecent] 查询结果数量:', sessions.length)
return sessions
} catch (error) {
console.error('查询最近会话失败:', error)
return []
}
})
// ==================== 增量导入 ====================
/**

View File

@@ -307,6 +307,8 @@ export interface ChatSessionItem {
endTs: number
messageCount: number
firstMessageId: number
/** 会话摘要(如果有) */
summary?: string | null
}
/**
@@ -321,13 +323,14 @@ export function getSessions(sessionId: string): ChatSessionItem[] {
}
try {
// 查询会话列表,同时获取每个会话的首条消息 ID
// 查询会话列表,同时获取每个会话的首条消息 ID 和摘要
const sql = `
SELECT
cs.id,
cs.start_ts as startTs,
cs.end_ts as endTs,
cs.message_count as messageCount,
cs.summary,
(SELECT mc.message_id FROM message_context mc WHERE mc.session_id = cs.id ORDER BY mc.message_id LIMIT 1) as firstMessageId
FROM chat_session cs
ORDER BY cs.start_ts ASC
@@ -341,6 +344,54 @@ export function getSessions(sessionId: string): ChatSessionItem[] {
}
}
// ==================== 会话摘要相关函数 ====================
/**
* 保存会话摘要
* @param sessionId 数据库会话ID
* @param chatSessionId 会话索引中的会话ID
* @param summary 摘要内容
*/
export function saveSessionSummary(sessionId: string, chatSessionId: number, summary: string): void {
// 先关闭缓存的只读连接
closeDatabase(sessionId)
const db = openWritableDatabase(sessionId)
if (!db) {
throw new Error(`无法打开数据库: ${sessionId}`)
}
try {
db.prepare('UPDATE chat_session SET summary = ? WHERE id = ?').run(summary, chatSessionId)
} finally {
db.close()
}
}
/**
* 获取会话摘要
* @param sessionId 数据库会话ID
* @param chatSessionId 会话索引中的会话ID
* @returns 摘要内容
*/
export function getSessionSummary(sessionId: string, chatSessionId: number): string | null {
const db = openReadonlyDatabase(sessionId)
if (!db) {
return null
}
try {
const result = db.prepare('SELECT summary FROM chat_session WHERE id = ?').get(chatSessionId) as
| { summary: string | null }
| undefined
return result?.summary || null
} catch {
return null
} finally {
db.close()
}
}
// ==================== AI 工具专用查询函数 ====================
/**
@@ -532,6 +583,7 @@ export function getSessionMessages(
| undefined
if (!session) {
db.close()
return null
}

View File

@@ -644,6 +644,8 @@ interface ChatSessionItem {
endTs: number
messageCount: number
firstMessageId: number
/** 会话摘要(如果有) */
summary?: string | null
}
interface SessionApi {
@@ -653,6 +655,46 @@ interface SessionApi {
clear: (sessionId: string) => Promise<boolean>
updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise<boolean>
getSessions: (sessionId: string) => Promise<ChatSessionItem[]>
/** 生成单个会话摘要 */
generateSummary: (
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
) => Promise<{ success: boolean; summary?: string; error?: string }>
/** 批量生成会话摘要 */
generateSummaries: (
dbSessionId: string,
chatSessionIds: number[],
locale?: string
) => Promise<{ success: number; failed: number; skipped: number }>
/** 根据时间范围查询会话列表 */
getByTimeRange: (
dbSessionId: string,
startTs: number,
endTs: number
) => Promise<
Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
>
/** 获取最近 N 条会话 */
getRecent: (
dbSessionId: string,
limit: number
) => Promise<
Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
>
}
declare global {

View File

@@ -1335,6 +1335,66 @@ const sessionApi = {
getSessions: (sessionId: string): Promise<ChatSessionItem[]> => {
return ipcRenderer.invoke('session:getSessions', sessionId)
},
/**
* 生成单个会话摘要
*/
generateSummary: (
dbSessionId: string,
chatSessionId: number,
locale?: string,
forceRegenerate?: boolean
): Promise<{ success: boolean; summary?: string; error?: string }> => {
return ipcRenderer.invoke('session:generateSummary', dbSessionId, chatSessionId, locale, forceRegenerate)
},
/**
* 批量生成会话摘要
*/
generateSummaries: (
dbSessionId: string,
chatSessionIds: number[],
locale?: string
): Promise<{ success: number; failed: number; skipped: number }> => {
return ipcRenderer.invoke('session:generateSummaries', dbSessionId, chatSessionIds, locale)
},
/**
* 根据时间范围查询会话列表
*/
getByTimeRange: (
dbSessionId: string,
startTs: number,
endTs: number
): Promise<
Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
> => {
return ipcRenderer.invoke('session:getByTimeRange', dbSessionId, startTs, endTs)
},
/**
* 获取最近 N 条会话
*/
getRecent: (
dbSessionId: string,
limit: number
): Promise<
Array<{
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}>
> => {
return ipcRenderer.invoke('session:getRecent', dbSessionId, limit)
},
}
// 扩展 api添加 dialog、clipboard 和应用功能

View File

@@ -0,0 +1,488 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDateFormat } from '@vueuse/core'
const props = defineProps<{
open: boolean
sessionId: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'completed': []
}>()
const { t, locale } = useI18n()
// 计算属性:双向绑定 open
const isOpen = computed({
get: () => props.open,
set: (val) => emit('update:open', val),
})
// 查询模式:按时间 / 按数量
type QueryMode = 'time' | 'count'
const queryMode = ref<QueryMode>('count')
// 按数量选项
type CountPreset = 50 | 100 | 200 | 500
const selectedCount = ref<CountPreset>(100)
// 时间范围选项
type TimeRangePreset = 'today' | 'yesterday' | 'week' | 'month' | 'custom'
const selectedPreset = ref<TimeRangePreset>('today')
// 自定义时间范围
const customStartDate = ref<Date | null>(null)
const customEndDate = ref<Date | null>(null)
// 会话列表
interface SessionItem {
id: number
startTs: number
endTs: number
messageCount: number
summary: string | null
}
const sessions = ref<SessionItem[]>([])
const isLoading = ref(false)
// 生成状态
const isGenerating = ref(false)
const currentIndex = ref(0)
const totalToGenerate = ref(0) // 记录开始时的总数
const results = ref<Array<{ id: number; status: 'success' | 'failed' | 'skipped'; message?: string }>>([])
const shouldStop = ref(false)
// 滚动容器引用
const resultsContainer = ref<HTMLElement | null>(null)
// 计算时间范围
const timeRange = computed(() => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (selectedPreset.value) {
case 'today':
return {
start: today.getTime(),
end: now.getTime(),
}
case 'yesterday': {
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
return {
start: yesterday.getTime(),
end: today.getTime() - 1,
}
}
case 'week': {
const weekAgo = new Date(today)
weekAgo.setDate(weekAgo.getDate() - 7)
return {
start: weekAgo.getTime(),
end: now.getTime(),
}
}
case 'month': {
const monthAgo = new Date(today)
monthAgo.setMonth(monthAgo.getMonth() - 1)
return {
start: monthAgo.getTime(),
end: now.getTime(),
}
}
case 'custom':
if (customStartDate.value && customEndDate.value) {
return {
start: customStartDate.value.getTime(),
end: new Date(customEndDate.value.getTime() + 24 * 60 * 60 * 1000 - 1).getTime(), // 当天结束
}
}
return null
default:
return null
}
})
// 待生成的会话(排除已有摘要的)
const pendingSessions = computed(() => {
return sessions.value.filter((s) => !s.summary)
})
// 已有摘要的会话数
const existingSummaryCount = computed(() => {
return sessions.value.filter((s) => s.summary).length
})
// 进度百分比
const progressPercent = computed(() => {
if (totalToGenerate.value === 0) return 100
return Math.round((currentIndex.value / totalToGenerate.value) * 100)
})
// 统计结果
const stats = computed(() => {
const success = results.value.filter((r) => r.status === 'success').length
const failed = results.value.filter((r) => r.status === 'failed').length
const skipped = results.value.filter((r) => r.status === 'skipped').length
return { success, failed, skipped }
})
// 查询会话
async function fetchSessions() {
isLoading.value = true
try {
if (queryMode.value === 'count') {
// 按数量查询
sessions.value = await window.sessionApi.getRecent(props.sessionId, selectedCount.value)
} else {
// 按时间查询
if (!timeRange.value) {
sessions.value = []
return
}
// 将时间戳转换为秒(数据库中使用秒)
const startTs = Math.floor(timeRange.value.start / 1000)
const endTs = Math.floor(timeRange.value.end / 1000)
sessions.value = await window.sessionApi.getByTimeRange(props.sessionId, startTs, endTs)
}
} catch (error) {
console.error('查询会话失败:', error)
sessions.value = []
} finally {
isLoading.value = false
}
}
// 监听查询条件变化
watch(
() => [queryMode.value, selectedCount.value, selectedPreset.value, customStartDate.value, customEndDate.value],
() => {
if (queryMode.value === 'count') {
fetchSessions()
} else if (selectedPreset.value !== 'custom' || (customStartDate.value && customEndDate.value)) {
fetchSessions()
}
},
{ immediate: true }
)
// 监听弹窗打开
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
// 重置状态
isGenerating.value = false
currentIndex.value = 0
results.value = []
shouldStop.value = false
fetchSessions()
}
}
)
// 开始生成
async function startGenerate() {
// 复制一份静态数组,避免在循环中因 computed 值变化导致问题
const sessionsToProcess = [...pendingSessions.value]
if (sessionsToProcess.length === 0) return
isGenerating.value = true
shouldStop.value = false
currentIndex.value = 0
totalToGenerate.value = sessionsToProcess.length
results.value = []
for (const session of sessionsToProcess) {
if (shouldStop.value) break
try {
const result = await window.sessionApi.generateSummary(
props.sessionId,
session.id,
locale.value,
false
)
if (result.success) {
results.value.push({ id: session.id, status: 'success' })
// 更新本地会话数据
const idx = sessions.value.findIndex((s) => s.id === session.id)
if (idx !== -1) {
sessions.value[idx].summary = result.summary || ''
}
} else {
results.value.push({ id: session.id, status: 'failed', message: result.error })
}
} catch (error) {
results.value.push({ id: session.id, status: 'failed', message: String(error) })
}
currentIndex.value++
// 自动滚动到底部
await nextTick()
if (resultsContainer.value) {
resultsContainer.value.scrollTop = resultsContainer.value.scrollHeight
}
}
isGenerating.value = false
// 如果有成功生成的,通知父组件刷新
if (stats.value.success > 0) {
emit('completed')
}
}
// 停止生成
function stopGenerate() {
shouldStop.value = true
}
// 关闭弹窗
function close() {
if (isGenerating.value) {
shouldStop.value = true
}
emit('update:open', false)
}
// 格式化时间戳
function formatTs(ts: number) {
return useDateFormat(new Date(ts * 1000), 'MM-DD HH:mm').value
}
</script>
<template>
<UModal v-model:open="isOpen" :ui="{ overlay: 'z-[10001]', content: 'z-[10001]' }">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ t('chatRecord.batchSummary.title', '批量生成摘要') }}</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark"
size="sm"
@click="close"
/>
</div>
</template>
<div class="space-y-4">
<!-- 查询模式切换 -->
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700 pb-3">
<UButton
:color="queryMode === 'count' ? 'primary' : 'neutral'"
:variant="queryMode === 'count' ? 'solid' : 'ghost'"
size="sm"
@click="queryMode = 'count'"
:disabled="isGenerating"
>
{{ t('chatRecord.batchSummary.byCount', '按数量') }}
</UButton>
<UButton
:color="queryMode === 'time' ? 'primary' : 'neutral'"
:variant="queryMode === 'time' ? 'solid' : 'ghost'"
size="sm"
@click="queryMode = 'time'"
:disabled="isGenerating"
>
{{ t('chatRecord.batchSummary.byTime', '按时间') }}
</UButton>
</div>
<!-- 按数量选择 -->
<div v-if="queryMode === 'count'">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('chatRecord.batchSummary.selectCount', '选择数量') }}
</label>
<div class="flex flex-wrap gap-2">
<UButton
v-for="count in [50, 100, 200, 500]"
:key="count"
:color="selectedCount === count ? 'primary' : 'neutral'"
:variant="selectedCount === count ? 'solid' : 'outline'"
size="sm"
@click="selectedCount = count as CountPreset"
:disabled="isGenerating"
>
{{ t('chatRecord.batchSummary.recent', '最近') }} {{ count }} {{ t('chatRecord.batchSummary.sessions', '次') }}
</UButton>
</div>
</div>
<!-- 按时间范围选择 -->
<div v-else>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t('chatRecord.batchSummary.timeRange', '选择时间范围') }}
</label>
<div class="flex flex-wrap gap-2">
<UButton
v-for="preset in [
{ key: 'today', label: t('chatRecord.batchSummary.today', '今天') },
{ key: 'yesterday', label: t('chatRecord.batchSummary.yesterday', '昨天') },
{ key: 'week', label: t('chatRecord.batchSummary.week', '最近7天') },
{ key: 'month', label: t('chatRecord.batchSummary.month', '最近30天') },
{ key: 'custom', label: t('chatRecord.batchSummary.custom', '自定义') },
]"
:key="preset.key"
:color="selectedPreset === preset.key ? 'primary' : 'neutral'"
:variant="selectedPreset === preset.key ? 'solid' : 'outline'"
size="sm"
@click="selectedPreset = preset.key as TimeRangePreset"
:disabled="isGenerating"
>
{{ preset.label }}
</UButton>
</div>
<!-- 自定义日期选择 -->
<div v-if="selectedPreset === 'custom'" class="mt-3 flex items-center gap-2">
<UInput
type="date"
v-model="customStartDate"
:disabled="isGenerating"
size="sm"
/>
<span class="text-gray-500">—</span>
<UInput
type="date"
v-model="customEndDate"
:disabled="isGenerating"
size="sm"
/>
</div>
</div>
<!-- 会话预览 -->
<div v-if="!isLoading" class="text-sm text-gray-600 dark:text-gray-400">
<template v-if="sessions.length > 0">
<p>
{{ t('chatRecord.batchSummary.found', '找到') }} {{ sessions.length }} {{ t('chatRecord.batchSummary.sessionsUnit', '个会话') }}
<template v-if="existingSummaryCount > 0">
<span class="text-green-600 dark:text-green-400">
{{ existingSummaryCount }} {{ t('chatRecord.batchSummary.existingSkip', '个已有摘要将跳过') }}
</span>
</template>
</p>
<p v-if="pendingSessions.length > 0" class="mt-1">
{{ t('chatRecord.batchSummary.pending', '待生成:') }} {{ pendingSessions.length }} {{ t('chatRecord.batchSummary.unit', '个') }}
</p>
</template>
<p v-else class="text-gray-400">
{{ t('chatRecord.batchSummary.noSessions', '该时间范围内没有会话') }}
</p>
</div>
<div v-else class="flex items-center gap-2 text-sm text-gray-500">
<UIcon name="i-heroicons-arrow-path" class="animate-spin" />
{{ t('chatRecord.batchSummary.loading', '加载中...') }}
</div>
<!-- 进度条 -->
<div v-if="isGenerating || results.length > 0" class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span>{{ t('chatRecord.batchSummary.progress', '进度') }}</span>
<span>{{ currentIndex }} / {{ totalToGenerate || pendingSessions.length }}</span>
</div>
<UProgress :value="progressPercent" />
</div>
<!-- 结果列表 -->
<div
v-if="results.length > 0"
ref="resultsContainer"
class="max-h-48 overflow-y-auto rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"
>
<div
v-for="result in results"
:key="result.id"
class="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-200 dark:border-gray-700 last:border-b-0"
>
<UIcon
:name="result.status === 'success' ? 'i-heroicons-check-circle' : result.status === 'skipped' ? 'i-heroicons-minus-circle' : 'i-heroicons-x-circle'"
:class="{
'text-green-500': result.status === 'success',
'text-gray-400': result.status === 'skipped',
'text-red-500': result.status === 'failed',
}"
/>
<span class="flex-1">
{{ t('chatRecord.batchSummary.session', '会话') }} #{{ result.id }}
<span v-if="result.status === 'failed' && result.message" class="text-red-500 text-xs ml-1">
({{ result.message }})
</span>
</span>
<span
:class="{
'text-green-600 dark:text-green-400': result.status === 'success',
'text-gray-500': result.status === 'skipped',
'text-red-600 dark:text-red-400': result.status === 'failed',
}"
>
{{
result.status === 'success'
? t('chatRecord.batchSummary.statusSuccess', '成功')
: result.status === 'skipped'
? t('chatRecord.batchSummary.statusSkipped', '跳过')
: t('chatRecord.batchSummary.statusFailed', '失败')
}}
</span>
</div>
</div>
<!-- 统计结果 -->
<div v-if="!isGenerating && results.length > 0" class="flex items-center gap-4 text-sm">
<span class="text-green-600 dark:text-green-400">
<UIcon name="i-heroicons-check-circle" class="mr-1" />
{{ t('chatRecord.batchSummary.success', '成功:') }} {{ stats.success }}
</span>
<span v-if="stats.failed > 0" class="text-red-600 dark:text-red-400">
<UIcon name="i-heroicons-x-circle" class="mr-1" />
{{ t('chatRecord.batchSummary.failed', '失败:') }} {{ stats.failed }}
</span>
<span v-if="stats.skipped > 0" class="text-gray-500">
<UIcon name="i-heroicons-minus-circle" class="mr-1" />
{{ t('chatRecord.batchSummary.skipped', '跳过:') }} {{ stats.skipped }}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="close"
:disabled="isGenerating"
>
{{ t('common.close', '关闭') }}
</UButton>
<UButton
v-if="!isGenerating"
color="primary"
@click="startGenerate"
:disabled="pendingSessions.length === 0 || isLoading"
>
{{ t('chatRecord.batchSummary.start', '开始生成') }}
</UButton>
<UButton
v-else
color="error"
@click="stopGenerate"
>
{{ t('chatRecord.batchSummary.stop', '停止') }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -6,6 +6,7 @@
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVirtualizer } from '@tanstack/vue-virtual'
import BatchSummaryModal from './BatchSummaryModal.vue'
interface ChatSessionItem {
id: number
@@ -13,6 +14,8 @@ interface ChatSessionItem {
endTs: number
messageCount: number
firstMessageId: number
/** 会话摘要(如果有) */
summary?: string | null
}
// 扁平化列表项类型
@@ -35,13 +38,19 @@ const emit = defineEmits<{
(e: 'update:collapsed', value: boolean): void
}>()
const { t } = useI18n()
const { t, locale } = useI18n()
// 状态
const allSessions = ref<ChatSessionItem[]>([])
const isLoading = ref(true)
const scrollContainerRef = ref<HTMLElement | null>(null)
// 正在生成摘要的会话 ID 集合
const generatingSummaryIds = ref<Set<number>>(new Set())
// 批量生成弹窗状态
const showBatchSummaryModal = ref(false)
// 是否折叠
const isCollapsed = computed({
get: () => props.collapsed ?? false,
@@ -95,7 +104,7 @@ const flatList = computed<FlatListItem[]>(() => {
// 估算项目高度
const ESTIMATED_DATE_HEIGHT = 28 // 日期头高度
const ESTIMATED_SESSION_HEIGHT = 28 // 会话项高度
const ESTIMATED_SESSION_HEIGHT = 60 // 会话项高度(含两行摘要)
// 虚拟化器
const virtualizer = useVirtualizer(
@@ -178,6 +187,49 @@ function handleSelectSession(session: ChatSessionItem) {
emit('select', session.id, session.firstMessageId)
}
// 生成摘要
async function generateSummary(session: ChatSessionItem, event: Event) {
event.stopPropagation() // 防止触发选择会话
event.preventDefault()
console.log('[SessionTimeline] 开始生成摘要:', session.id, props.sessionId)
if (generatingSummaryIds.value.has(session.id)) {
console.log('[SessionTimeline] 已在生成中,跳过')
return
}
generatingSummaryIds.value.add(session.id)
console.log('[SessionTimeline] 正在生成中的会话:', Array.from(generatingSummaryIds.value))
try {
console.log('[SessionTimeline] 调用 IPC...')
const result = await window.sessionApi.generateSummary(props.sessionId, session.id, locale.value)
console.log('[SessionTimeline] IPC 返回:', result)
if (result.success && result.summary) {
// 更新本地数据
const index = allSessions.value.findIndex((s) => s.id === session.id)
if (index !== -1) {
allSessions.value[index] = { ...allSessions.value[index], summary: result.summary }
console.log('[SessionTimeline] 摘要已更新:', result.summary)
}
} else {
console.log('[SessionTimeline] 生成失败:', result.error)
}
} catch (error) {
console.error('[SessionTimeline] 生成摘要失败:', error)
} finally {
generatingSummaryIds.value.delete(session.id)
console.log('[SessionTimeline] 生成完成')
}
}
// 判断是否正在生成摘要
function isGenerating(sessionId: number): boolean {
return generatingSummaryIds.value.has(sessionId)
}
// 测量元素高度
function measureElement(el: Element | null) {
if (el) {
@@ -229,7 +281,17 @@ onMounted(() => {
<!-- 头部 -->
<div class="flex items-center justify-between border-b border-gray-200 px-2 py-1.5 dark:border-gray-700">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('timeline') }}</span>
<UButton icon="i-heroicons-chevron-left" variant="ghost" size="xs" @click="isCollapsed = true" />
<div class="flex items-center gap-0.5">
<UTooltip :text="t('chatRecord.batchSummary.title', '批量生成摘要')">
<UButton
icon="i-heroicons-sparkles"
variant="ghost"
size="xs"
@click="showBatchSummaryModal = true"
/>
</UTooltip>
<UButton icon="i-heroicons-chevron-left" variant="ghost" size="xs" @click="isCollapsed = true" />
</div>
</div>
<!-- 加载中 -->
@@ -267,7 +329,7 @@ onMounted(() => {
<!-- 会话项 -->
<template v-else-if="flatList[virtualItem.index]?.type === 'session'">
<button
class="flex w-full items-center justify-between rounded px-2 py-1 pl-4 text-left transition-colors"
class="flex w-full flex-col rounded px-2 py-1 pl-4 text-left transition-colors"
:class="[
activeSessionId === (flatList[virtualItem.index] as { session: ChatSessionItem }).session.id
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
@@ -275,18 +337,67 @@ onMounted(() => {
]"
@click="handleSelectSession((flatList[virtualItem.index] as { session: ChatSessionItem }).session)"
>
<span class="text-xs text-gray-600 dark:text-gray-300">
{{ formatTime((flatList[virtualItem.index] as { session: ChatSessionItem }).session.startTs) }}
</span>
<span class="text-xs text-gray-400">
({{ (flatList[virtualItem.index] as { session: ChatSessionItem }).session.messageCount }})
</span>
<!-- 时间和消息数 -->
<div class="flex w-full items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-300">
{{ formatTime((flatList[virtualItem.index] as { session: ChatSessionItem }).session.startTs) }}
</span>
<span class="text-xs text-gray-400">
({{ (flatList[virtualItem.index] as { session: ChatSessionItem }).session.messageCount }})
</span>
</div>
<!-- 摘要或生成按钮 -->
<div class="mt-0.5 flex w-full items-center">
<!-- 有摘要显示摘要两行 -->
<UTooltip
v-if="(flatList[virtualItem.index] as { session: ChatSessionItem }).session.summary"
:popper="{ placement: 'right' }"
:ui="{ content: 'z-[10001] h-auto max-h-80 overflow-y-auto' }"
>
<span class="line-clamp-2 text-xs leading-tight text-gray-400 dark:text-gray-500">
{{ (flatList[virtualItem.index] as { session: ChatSessionItem }).session.summary }}
</span>
<template #content>
<div class="max-w-sm whitespace-pre-wrap text-sm leading-relaxed">
{{ (flatList[virtualItem.index] as { session: ChatSessionItem }).session.summary }}
</div>
</template>
</UTooltip>
<!-- 无摘要且消息数>=3显示生成按钮 -->
<span
v-else-if="(flatList[virtualItem.index] as { session: ChatSessionItem }).session.messageCount >= 3"
class="flex items-center gap-1 text-xs text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400"
@click="generateSummary((flatList[virtualItem.index] as { session: ChatSessionItem }).session, $event)"
>
<UIcon
v-if="isGenerating((flatList[virtualItem.index] as { session: ChatSessionItem }).session.id)"
name="i-heroicons-arrow-path"
class="h-3 w-3 animate-spin"
/>
<UIcon v-else name="i-heroicons-sparkles" class="h-3 w-3" />
<span>{{ t('generateSummary') }}</span>
</span>
<!-- 消息数<3显示提示 -->
<span v-else class="text-xs italic text-gray-300 dark:text-gray-600">
{{ t('tooFewMessages') }}
</span>
</div>
</button>
</template>
</div>
</div>
</div>
</div>
<!-- 批量生成摘要弹窗 -->
<BatchSummaryModal
v-model:open="showBatchSummaryModal"
:session-id="sessionId"
@completed="loadSessions"
/>
</template>
<style scoped>
@@ -300,11 +411,15 @@ onMounted(() => {
{
"zh-CN": {
"timeline": "会话",
"noSessions": "暂无会话"
"noSessions": "暂无会话",
"generateSummary": "生成摘要",
"tooFewMessages": "消息太少"
},
"en-US": {
"timeline": "Sessions",
"noSessions": "No sessions"
"noSessions": "No sessions",
"generateSummary": "Summarize",
"tooFewMessages": "Too few"
}
}
</i18n>