mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-28 07:42:41 +08:00
feat: 聊天会话支持摘要功能
This commit is contained in:
456
electron/main/ai/summary/index.ts
Normal file
456
electron/main/ai/summary/index.ts
Normal 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 }
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 增量导入 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
42
electron/preload/index.d.ts
vendored
42
electron/preload/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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 和应用功能
|
||||
|
||||
488
src/components/common/ChatRecord/BatchSummaryModal.vue
Normal file
488
src/components/common/ChatRecord/BatchSummaryModal.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user