diff --git a/electron/main/database/core.ts b/electron/main/database/core.ts index 4dbd198..8a3f1ef 100644 --- a/electron/main/database/core.ts +++ b/electron/main/database/core.ts @@ -59,7 +59,8 @@ function createDatabase(sessionId: string): Database.Database { group_id TEXT, group_avatar TEXT, owner_id TEXT, - schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION}, + session_gap_threshold INTEGER ); CREATE TABLE IF NOT EXISTS member ( @@ -95,10 +96,27 @@ function createDatabase(sessionId: string): Database.Database { FOREIGN KEY(sender_id) REFERENCES member(id) ); + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts); CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id); CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); + CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts); + CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id); `) return db @@ -424,7 +442,12 @@ export function checkMigrationNeeded(): { } } - return { count: needsMigrationList.length, sessionIds: needsMigrationList, lowestVersion, forceRepairIds: forceRepairList } + return { + count: needsMigrationList.length, + sessionIds: needsMigrationList, + lowestVersion, + forceRepairIds: forceRepairList, + } } /** diff --git a/electron/main/database/migrations.ts b/electron/main/database/migrations.ts index 1cba38a..3069f4e 100644 --- a/electron/main/database/migrations.ts +++ b/electron/main/database/migrations.ts @@ -34,7 +34,7 @@ export interface MigrationInfo { } /** 当前 schema 版本(最新迁移的版本号) */ -export const CURRENT_SCHEMA_VERSION = 2 +export const CURRENT_SCHEMA_VERSION = 3 /** * 迁移脚本列表 @@ -89,6 +89,54 @@ const migrations: Migration[] = [ } }, }, + { + version: 3, + description: '添加会话索引相关表(chat_session、message_context)和 session_gap_threshold 字段', + userMessage: '支持会话时间轴浏览和 AI 增强分析功能', + up: (db) => { + // 创建 chat_session 会话表 + db.exec(` + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ) + `) + + // 创建会话时间索引 + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') + } catch { + // 索引可能已存在 + } + + // 创建 message_context 消息上下文表(预留 topic_id) + db.exec(` + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ) + `) + + // 创建 session_id 索引 + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') + } catch { + // 索引可能已存在 + } + + // 检查 meta 表是否已有 session_gap_threshold 列 + const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasGapThresholdColumn = tableInfo.some((col) => col.name === 'session_gap_threshold') + if (!hasGapThresholdColumn) { + db.exec('ALTER TABLE meta ADD COLUMN session_gap_threshold INTEGER') + } + }, + }, ] /** @@ -149,9 +197,7 @@ export function migrateDatabase(db: Database.Database, forceRepair = false): boo // 获取需要执行的迁移 // 如果是强制修复,从 version 0 开始执行所有迁移 - const pendingMigrations = forceRepair - ? migrations - : migrations.filter((m) => m.version > currentVersion) + const pendingMigrations = forceRepair ? migrations : migrations.filter((m) => m.version > currentVersion) if (pendingMigrations.length === 0) { return false diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts index 8fd31a9..e4aa0fa 100644 --- a/electron/main/ipc/chat.ts +++ b/electron/main/ipc/chat.ts @@ -623,4 +623,80 @@ export function registerChatHandlers(ctx: IpcContext): void { return [] } }) + + // ==================== 会话索引 ==================== + + /** + * 生成会话索引 + */ + ipcMain.handle('session:generate', async (_, sessionId: string, gapThreshold?: number) => { + try { + return await worker.generateSessions(sessionId, gapThreshold) + } catch (error) { + console.error('生成会话索引失败:', error) + throw error + } + }) + + /** + * 检查是否已生成会话索引 + */ + ipcMain.handle('session:hasIndex', async (_, sessionId: string) => { + try { + return await worker.hasSessionIndex(sessionId) + } catch (error) { + console.error('检查会话索引失败:', error) + return false + } + }) + + /** + * 获取会话索引统计信息 + */ + ipcMain.handle('session:getStats', async (_, sessionId: string) => { + try { + return await worker.getSessionStats(sessionId) + } catch (error) { + console.error('获取会话统计失败:', error) + return { sessionCount: 0, hasIndex: false, gapThreshold: 1800 } + } + }) + + /** + * 清空会话索引 + */ + ipcMain.handle('session:clear', async (_, sessionId: string) => { + try { + await worker.clearSessions(sessionId) + return true + } catch (error) { + console.error('清空会话索引失败:', error) + return false + } + }) + + /** + * 更新会话切分阈值 + */ + ipcMain.handle('session:updateGapThreshold', async (_, sessionId: string, gapThreshold: number | null) => { + try { + await worker.updateSessionGapThreshold(sessionId, gapThreshold) + return true + } catch (error) { + console.error('更新阈值失败:', error) + return false + } + }) + + /** + * 获取会话列表(用于时间线导航) + */ + ipcMain.handle('session:getSessions', async (_, sessionId: string) => { + try { + return await worker.getSessions(sessionId) + } catch (error) { + console.error('获取会话列表失败:', error) + return [] + } + }) } diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts index 00269a4..1858709 100644 --- a/electron/main/worker/dbWorker.ts +++ b/electron/main/worker/dbWorker.ts @@ -47,6 +47,13 @@ import { // SQL 实验室 executeRawSQL, getSchema, + // 会话索引 + generateSessions, + clearSessions, + hasSessionIndex, + getSessionStats, + updateSessionGapThreshold, + getSessions, } from './query' import { streamImport, streamParseFileInfo } from './import' @@ -115,6 +122,14 @@ const syncHandlers: Record any> = { // SQL 实验室 executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), getSchema: (p) => getSchema(p.sessionId), + + // 会话索引 + generateSessions: (p) => generateSessions(p.sessionId, p.gapThreshold), + clearSessions: (p) => clearSessions(p.sessionId), + hasSessionIndex: (p) => hasSessionIndex(p.sessionId), + getSessionStats: (p) => getSessionStats(p.sessionId), + updateSessionGapThreshold: (p) => updateSessionGapThreshold(p.sessionId, p.gapThreshold), + getSessions: (p) => getSessions(p.sessionId), } // 异步消息处理器(流式操作) diff --git a/electron/main/worker/import/streamImport.ts b/electron/main/worker/import/streamImport.ts index bbd55c6..c3db48a 100644 --- a/electron/main/worker/import/streamImport.ts +++ b/electron/main/worker/import/streamImport.ts @@ -150,7 +150,8 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database { group_id TEXT, group_avatar TEXT, owner_id TEXT, - schema_version INTEGER DEFAULT 2 + schema_version INTEGER DEFAULT 3, + session_gap_threshold INTEGER ); CREATE TABLE IF NOT EXISTS member ( @@ -185,6 +186,21 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database { platform_message_id TEXT DEFAULT NULL, FOREIGN KEY(sender_id) REFERENCES member(id) ); + + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ); `) return db @@ -199,6 +215,8 @@ function createIndexes(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id); CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); + CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts); + CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id); `) } @@ -440,7 +458,13 @@ export async function streamImport(filePath: string, requestId: string): Promise let t0 = Date.now() if (!memberIdMap.has(msg.senderPlatformId)) { // 消息中没有头像和角色信息,设为默认值 - insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null, '[]') + insertMember.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + null, + '[]' + ) const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined if (row) { memberIdMap.set(msg.senderPlatformId, row.id) diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts index aa13362..6c6582b 100644 --- a/electron/main/worker/query/index.ts +++ b/electron/main/worker/query/index.ts @@ -53,3 +53,15 @@ export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './mess // SQL 实验室 export { executeRawSQL, getSchema } from './sql' export type { SQLResult, TableSchema } from './sql' + +// 会话索引 +export { + generateSessions, + clearSessions, + hasSessionIndex, + getSessionStats, + updateSessionGapThreshold, + getSessions, + DEFAULT_SESSION_GAP_THRESHOLD, +} from './session' +export type { ChatSessionItem } from './session' diff --git a/electron/main/worker/query/session.ts b/electron/main/worker/query/session.ts new file mode 100644 index 0000000..7a0b6f0 --- /dev/null +++ b/electron/main/worker/query/session.ts @@ -0,0 +1,342 @@ +/** + * 会话索引模块 + * 提供基于时间间隔的会话切分算法 + */ + +import Database from 'better-sqlite3' +import { getDbPath, closeDatabase } from '../core' + +/** 默认会话切分阈值:30分钟(秒) */ +export const DEFAULT_SESSION_GAP_THRESHOLD = 1800 + +/** + * 打开数据库(可写模式,不使用缓存) + * 会话索引需要写入数据 + */ +function openWritableDatabase(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + return db + } catch { + return null + } +} + +/** + * 打开数据库(只读模式,不使用缓存) + */ +function openReadonlyDatabase(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + try { + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + return db + } catch { + return null + } +} + +/** + * 生成会话索引 + * 使用 Gap-based 算法,根据消息时间间隔自动切分会话 + * + * @param sessionId 数据库会话ID + * @param gapThreshold 时间间隔阈值(秒),默认 1800(30分钟) + * @param onProgress 进度回调 + * @returns 生成的会话数量 + */ +export function generateSessions( + sessionId: string, + gapThreshold: number = DEFAULT_SESSION_GAP_THRESHOLD, + onProgress?: (current: number, total: number) => void +): number { + // 先关闭缓存的只读连接 + closeDatabase(sessionId) + + const db = openWritableDatabase(sessionId) + if (!db) { + throw new Error(`无法打开数据库: ${sessionId}`) + } + + try { + // 获取消息总数 + const countResult = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + const totalMessages = countResult.count + + if (totalMessages === 0) { + return 0 + } + + // 清空已有的会话数据 + clearSessionsInternal(db) + + // 使用窗口函数计算会话边界 + // 步骤1:为每条消息计算与前一条的时间差,标记新会话起点 + const sessionMarkSQL = ` + WITH message_ordered AS ( + SELECT + id, + ts, + LAG(ts) OVER (ORDER BY ts, id) AS prev_ts + FROM message + ), + session_marks AS ( + SELECT + id, + ts, + CASE + WHEN prev_ts IS NULL OR (ts - prev_ts) > ? THEN 1 + ELSE 0 + END AS is_new_session + FROM message_ordered + ), + session_ids AS ( + SELECT + id, + ts, + SUM(is_new_session) OVER (ORDER BY ts, id) AS session_num + FROM session_marks + ) + SELECT id, ts, session_num FROM session_ids + ` + + const messages = db.prepare(sessionMarkSQL).all(gapThreshold) as Array<{ + id: number + ts: number + session_num: number + }> + + if (messages.length === 0) { + return 0 + } + + // 步骤2:计算每个会话的统计信息 + const sessionMap = new Map() + + for (const msg of messages) { + const session = sessionMap.get(msg.session_num) + if (!session) { + sessionMap.set(msg.session_num, { + startTs: msg.ts, + endTs: msg.ts, + messageIds: [msg.id], + }) + } else { + session.endTs = msg.ts + session.messageIds.push(msg.id) + } + } + + // 步骤3:批量写入 chat_session 和 message_context 表 + const insertSession = db.prepare(` + INSERT INTO chat_session (start_ts, end_ts, message_count, is_manual, summary) + VALUES (?, ?, ?, 0, NULL) + `) + + const insertContext = db.prepare(` + INSERT INTO message_context (message_id, session_id, topic_id) + VALUES (?, ?, NULL) + `) + + // 开始事务 + const transaction = db.transaction(() => { + let processedCount = 0 + const totalSessions = sessionMap.size + + for (const [, sessionData] of sessionMap) { + // 插入会话记录 + const result = insertSession.run(sessionData.startTs, sessionData.endTs, sessionData.messageIds.length) + const newSessionId = result.lastInsertRowid as number + + // 批量插入消息上下文 + for (const messageId of sessionData.messageIds) { + insertContext.run(messageId, newSessionId) + } + + processedCount++ + if (onProgress && processedCount % 100 === 0) { + onProgress(processedCount, totalSessions) + } + } + + return totalSessions + }) + + const sessionCount = transaction() + + // 最终进度回调 + if (onProgress) { + onProgress(sessionCount, sessionCount) + } + + return sessionCount + } finally { + db.close() + } +} + +/** + * 清空会话索引数据 + * @param sessionId 数据库会话ID + */ +export function clearSessions(sessionId: string): void { + // 先关闭缓存的只读连接 + closeDatabase(sessionId) + + const db = openWritableDatabase(sessionId) + if (!db) { + throw new Error(`无法打开数据库: ${sessionId}`) + } + + try { + clearSessionsInternal(db) + } finally { + db.close() + } +} + +/** + * 内部清空会话数据函数 + */ +function clearSessionsInternal(db: Database.Database): void { + db.exec('DELETE FROM message_context') + db.exec('DELETE FROM chat_session') +} + +/** + * 检查是否已生成会话索引 + * @param sessionId 数据库会话ID + * @returns 是否有会话索引 + */ +export function hasSessionIndex(sessionId: string): boolean { + const db = openReadonlyDatabase(sessionId) + if (!db) { + return false + } + + try { + // 检查 chat_session 表是否存在且有数据 + const result = db.prepare('SELECT COUNT(*) as count FROM chat_session').get() as { count: number } + return result.count > 0 + } catch { + // 表可能不存在 + return false + } finally { + db.close() + } +} + +/** + * 获取会话索引统计信息 + * @param sessionId 数据库会话ID + */ +export function getSessionStats(sessionId: string): { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} { + const db = openReadonlyDatabase(sessionId) + if (!db) { + return { sessionCount: 0, hasIndex: false, gapThreshold: DEFAULT_SESSION_GAP_THRESHOLD } + } + + try { + // 获取会话数量 + let sessionCount = 0 + try { + const countResult = db.prepare('SELECT COUNT(*) as count FROM chat_session').get() as { count: number } + sessionCount = countResult.count + } catch { + // 表可能不存在 + } + + // 获取配置的阈值 + let gapThreshold = DEFAULT_SESSION_GAP_THRESHOLD + try { + const metaResult = db.prepare('SELECT session_gap_threshold FROM meta LIMIT 1').get() as + | { + session_gap_threshold: number | null + } + | undefined + if (metaResult?.session_gap_threshold) { + gapThreshold = metaResult.session_gap_threshold + } + } catch { + // 字段可能不存在 + } + + return { + sessionCount, + hasIndex: sessionCount > 0, + gapThreshold, + } + } finally { + db.close() + } +} + +/** + * 更新单个聊天的会话切分阈值 + * @param sessionId 数据库会话ID + * @param gapThreshold 阈值(秒),null 表示使用全局配置 + */ +export function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): void { + // 先关闭缓存的只读连接 + closeDatabase(sessionId) + + const db = openWritableDatabase(sessionId) + if (!db) { + throw new Error(`无法打开数据库: ${sessionId}`) + } + + try { + db.prepare('UPDATE meta SET session_gap_threshold = ?').run(gapThreshold) + } finally { + db.close() + } +} + +/** + * 会话列表项类型 + */ +export interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number +} + +/** + * 获取会话列表(用于时间线导航) + * @param sessionId 数据库会话ID + * @returns 会话列表,按时间排序 + */ +export function getSessions(sessionId: string): ChatSessionItem[] { + const db = openReadonlyDatabase(sessionId) + if (!db) { + return [] + } + + try { + // 查询会话列表,同时获取每个会话的首条消息 ID + const sql = ` + SELECT + cs.id, + cs.start_ts as startTs, + cs.end_ts as endTs, + cs.message_count as messageCount, + (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 + ` + const sessions = db.prepare(sql).all() as ChatSessionItem[] + return sessions + } catch { + return [] + } finally { + db.close() + } +} diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 502cd09..b9c26d8 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -494,3 +494,66 @@ export async function executeRawSQL(sessionId: string, sql: string): Promise { return sendToWorker('getSchema', { sessionId }) } + +// ==================== 会话索引 API ==================== + +export interface SessionStats { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} + +/** + * 生成会话索引 + * @param sessionId 数据库会话ID + * @param gapThreshold 时间间隔阈值(秒) + */ +export async function generateSessions(sessionId: string, gapThreshold?: number): Promise { + return sendToWorker('generateSessions', { sessionId, gapThreshold }) +} + +/** + * 清空会话索引 + */ +export async function clearSessions(sessionId: string): Promise { + return sendToWorker('clearSessions', { sessionId }) +} + +/** + * 检查是否已生成会话索引 + */ +export async function hasSessionIndex(sessionId: string): Promise { + return sendToWorker('hasSessionIndex', { sessionId }) +} + +/** + * 获取会话索引统计信息 + */ +export async function getSessionStats(sessionId: string): Promise { + return sendToWorker('getSessionStats', { sessionId }) +} + +/** + * 更新单个聊天的会话切分阈值 + */ +export async function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): Promise { + return sendToWorker('updateSessionGapThreshold', { sessionId, gapThreshold }) +} + +/** + * 会话列表项类型 + */ +export interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number +} + +/** + * 获取会话列表(用于时间线导航) + */ +export async function getSessions(sessionId: string): Promise { + return sendToWorker('getSessions', { sessionId }) +} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index abd6232..437a6f1 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -440,6 +440,30 @@ interface NetworkApi { testProxyConnection: (proxyUrl: string) => Promise<{ success: boolean; error?: string }> } +// Session Index API 类型 - 会话索引功能 +interface SessionStats { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} + +interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number +} + +interface SessionApi { + generate: (sessionId: string, gapThreshold?: number) => Promise + hasIndex: (sessionId: string) => Promise + getStats: (sessionId: string) => Promise + clear: (sessionId: string) => Promise + updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise + getSessions: (sessionId: string) => Promise +} + declare global { interface Window { electron: ElectronAPI @@ -451,6 +475,7 @@ declare global { agentApi: AgentApi cacheApi: CacheApi networkApi: NetworkApi + sessionApi: SessionApi } } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 35f63ed..7c8e197 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -1051,6 +1051,68 @@ const cacheApi = { }, } +// Session Index API - 会话索引功能 +interface SessionStats { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} + +interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number +} + +const sessionApi = { + /** + * 生成会话索引 + * @param sessionId 数据库会话ID + * @param gapThreshold 时间间隔阈值(秒) + * @returns 生成的会话数量 + */ + generate: (sessionId: string, gapThreshold?: number): Promise => { + return ipcRenderer.invoke('session:generate', sessionId, gapThreshold) + }, + + /** + * 检查是否已生成会话索引 + */ + hasIndex: (sessionId: string): Promise => { + return ipcRenderer.invoke('session:hasIndex', sessionId) + }, + + /** + * 获取会话索引统计信息 + */ + getStats: (sessionId: string): Promise => { + return ipcRenderer.invoke('session:getStats', sessionId) + }, + + /** + * 清空会话索引 + */ + clear: (sessionId: string): Promise => { + return ipcRenderer.invoke('session:clear', sessionId) + }, + + /** + * 更新会话切分阈值 + */ + updateGapThreshold: (sessionId: string, gapThreshold: number | null): Promise => { + return ipcRenderer.invoke('session:updateGapThreshold', sessionId, gapThreshold) + }, + + /** + * 获取会话列表(用于时间线导航) + */ + getSessions: (sessionId: string): Promise => { + return ipcRenderer.invoke('session:getSessions', sessionId) + }, +} + // 扩展 api,添加 dialog、clipboard 和应用功能 const extendedApi = { ...api, @@ -1122,6 +1184,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('agentApi', agentApi) contextBridge.exposeInMainWorld('cacheApi', cacheApi) contextBridge.exposeInMainWorld('networkApi', networkApi) + contextBridge.exposeInMainWorld('sessionApi', sessionApi) } catch (error) { console.error(error) } @@ -1144,4 +1207,6 @@ if (process.contextIsolated) { window.cacheApi = cacheApi // @ts-ignore (define in dts) window.networkApi = networkApi + // @ts-ignore (define in dts) + window.sessionApi = sessionApi } diff --git a/src/components/analysis/SessionIndexModal.vue b/src/components/analysis/SessionIndexModal.vue new file mode 100644 index 0000000..dea472a --- /dev/null +++ b/src/components/analysis/SessionIndexModal.vue @@ -0,0 +1,298 @@ + + + + + +{ + "zh-CN": { + "sessionIndex": { + "title": "会话索引", + "subtitle": "用于聊天记录的时间线导航", + "notGenerated": "尚未生成会话索引", + "notGeneratedHint": "生成会话索引后,您可以在聊天记录查看器中使用时间线快速导航到不同的会话。", + "whatIsIt": "会话索引的作用:", + "benefit1": "根据消息时间间隔自动识别会话边界", + "benefit2": "在聊天记录查看器中提供时间线导航", + "benefit3": "为 AI 分析提供对话上下文理解能力", + "generated": "会话索引已生成", + "sessionCount": "共识别出 {count} 个会话", + "regenerateHint": "如果您调整了会话间隔阈值,可以重新生成索引。", + "generate": "生成索引", + "regenerate": "重新生成", + "generating": "生成中...", + "cancel": "取消" + } + }, + "en-US": { + "sessionIndex": { + "title": "Session Index", + "subtitle": "For timeline navigation in chat records", + "notGenerated": "Session index not generated", + "notGeneratedHint": "After generating session index, you can use timeline navigation in the chat record viewer to quickly jump to different sessions.", + "whatIsIt": "What session index does:", + "benefit1": "Identify conversation boundaries based on message time gaps", + "benefit2": "Provide timeline navigation in chat record viewer", + "benefit3": "Enable AI analysis to understand conversation context", + "generated": "Session index generated", + "sessionCount": "{count} sessions identified", + "regenerateHint": "You can regenerate the index if you've adjusted the session gap threshold.", + "generate": "Generate Index", + "regenerate": "Regenerate", + "generating": "Generating...", + "cancel": "Cancel" + } + } +} + diff --git a/src/pages/group-chat/index.vue b/src/pages/group-chat/index.vue index a884e0e..9082842 100644 --- a/src/pages/group-chat/index.vue +++ b/src/pages/group-chat/index.vue @@ -14,6 +14,7 @@ import RankingTab from './components/RankingTab.vue' import QuotesTab from './components/QuotesTab.vue' import MemberTab from './components/MemberTab.vue' import PageHeader from '@/components/layout/PageHeader.vue' +import SessionIndexModal from '@/components/analysis/SessionIndexModal.vue' import { useSessionStore } from '@/stores/session' import { useLayoutStore } from '@/stores/layout' import { isFeatureSupported, type LocaleType } from '@/i18n' @@ -26,6 +27,9 @@ const sessionStore = useSessionStore() const layoutStore = useLayoutStore() const { currentSessionId } = storeToRefs(sessionStore) +// 会话索引弹窗状态 +const showSessionIndexModal = ref(false) + // 打开聊天记录查看器 function openChatRecordViewer() { layoutStore.openChatRecordDrawer({}) @@ -278,6 +282,15 @@ onMounted(() => { @click="openChatRecordViewer" /> + + + @@ -375,6 +388,9 @@ onMounted(() => {

{{ t('analysis.groupChat.loadError') }}

+ + + diff --git a/src/pages/private-chat/index.vue b/src/pages/private-chat/index.vue index 64208f4..9c0da3c 100644 --- a/src/pages/private-chat/index.vue +++ b/src/pages/private-chat/index.vue @@ -13,6 +13,7 @@ import OverviewTab from './components/OverviewTab.vue' import QuotesTab from './components/QuotesTab.vue' import MemberTab from './components/MemberTab.vue' import PageHeader from '@/components/layout/PageHeader.vue' +import SessionIndexModal from '@/components/analysis/SessionIndexModal.vue' import { useSessionStore } from '@/stores/session' import { useLayoutStore } from '@/stores/layout' @@ -24,6 +25,9 @@ const sessionStore = useSessionStore() const layoutStore = useLayoutStore() const { currentSessionId } = storeToRefs(sessionStore) +// 会话索引弹窗状态 +const showSessionIndexModal = ref(false) + // 打开聊天记录查看器 function openChatRecordViewer() { layoutStore.openChatRecordDrawer({}) @@ -250,6 +254,15 @@ onMounted(() => { @click="openChatRecordViewer" /> + + + @@ -330,6 +343,9 @@ onMounted(() => {

{{ t('analysis.privateChat.loadError') }}

+ + + diff --git a/src/stores/session.ts b/src/stores/session.ts index 5948c11..4cadf5f 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -203,6 +203,17 @@ export const useSessionStore = defineStore( if (importResult.success && importResult.sessionId) { await loadSessions() currentSessionId.value = importResult.sessionId + + // 自动生成会话索引 + try { + const savedThreshold = localStorage.getItem('sessionGapThreshold') + const gapThreshold = savedThreshold ? parseInt(savedThreshold, 10) : 1800 // 默认30分钟 + await window.sessionApi.generate(importResult.sessionId, gapThreshold) + } catch (error) { + console.error('自动生成会话索引失败:', error) + // 不阻断导入流程,用户可以手动生成 + } + return { success: true } } else { // 传递诊断信息(如果有) diff --git a/src/types/base.ts b/src/types/base.ts index 3cf4e59..d4b177f 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -142,6 +142,7 @@ export interface DbMeta { group_id: string | null // 群ID(群聊类型有值,私聊为空) group_avatar: string | null // 群头像(base64 Data URL) owner_id: string | null // 所有者/导出者的 platformId + session_gap_threshold: number | null // 会话切分阈值(秒),null 表示使用全局配置 } /** @@ -254,3 +255,46 @@ export interface ImportResult { sessionId?: string // 成功时返回会话ID error?: string // 失败时返回错误信息 } + +// ==================== 会话索引类型 ==================== + +/** + * 会话(时间切分的对话段落) + */ +export interface ChatSession { + id: number // 自增ID + startTs: number // 会话开始时间戳(秒) + endTs: number // 会话结束时间戳(秒) + messageCount: number // 该会话包含的消息数 + isManual: boolean // 是否用户手动合并/修改过 + summary: string | null // AI 生成的会话简报(预留) +} + +/** + * 消息上下文索引 + */ +export interface MessageContext { + messageId: number // 关联 message.id + sessionId: number // 关联 chat_session.id + topicId: number | null // 关联 chat_topic.id(预留) +} + +/** + * 会话索引配置 + */ +export interface SessionConfig { + /** 默认切分阈值(秒) */ + defaultGapThreshold: number +} + +/** + * 会话统计信息 + */ +export interface SessionStats { + /** 会话总数 */ + sessionCount: number + /** 是否已生成会话索引 */ + hasIndex: boolean + /** 当前使用的阈值(秒) */ + gapThreshold: number +}