diff --git a/electron/main/ai/summary/index.ts b/electron/main/ai/summary/index.ts new file mode 100644 index 0000000..610be70 --- /dev/null +++ b/electron/main/ai/summary/index.ts @@ -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> { + const segments: Array> = [] + 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 { + 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 { + // 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 } +} + diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 5a0275e..a3ee47c 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -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 [] + } + }) + // ==================== 增量导入 ==================== /** diff --git a/electron/main/worker/query/session.ts b/electron/main/worker/query/session.ts index 57d922d..bc11010 100644 --- a/electron/main/worker/query/session.ts +++ b/electron/main/worker/query/session.ts @@ -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 } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index 289bb31..579f0b7 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -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 updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise getSessions: (sessionId: string) => Promise + /** 生成单个会话摘要 */ + 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 { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index d8cf543..913cffe 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1335,6 +1335,66 @@ const sessionApi = { getSessions: (sessionId: string): Promise => { 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 和应用功能 diff --git a/src/components/common/ChatRecord/BatchSummaryModal.vue b/src/components/common/ChatRecord/BatchSummaryModal.vue new file mode 100644 index 0000000..70e90bd --- /dev/null +++ b/src/components/common/ChatRecord/BatchSummaryModal.vue @@ -0,0 +1,488 @@ + + + + diff --git a/src/components/common/ChatRecord/SessionTimeline.vue b/src/components/common/ChatRecord/SessionTimeline.vue index 6395d66..1d0dbc8 100644 --- a/src/components/common/ChatRecord/SessionTimeline.vue +++ b/src/components/common/ChatRecord/SessionTimeline.vue @@ -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([]) const isLoading = ref(true) const scrollContainerRef = ref(null) +// 正在生成摘要的会话 ID 集合 +const generatingSummaryIds = ref>(new Set()) + +// 批量生成弹窗状态 +const showBatchSummaryModal = ref(false) + // 是否折叠 const isCollapsed = computed({ get: () => props.collapsed ?? false, @@ -95,7 +104,7 @@ const flatList = computed(() => { // 估算项目高度 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(() => {
{{ t('timeline') }} - +
+ + + + +
@@ -267,7 +329,7 @@ onMounted(() => { + + +