mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-13 09:41:01 +08:00
feat: 导入聊天记录支持生成会话索引
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string, (payload: any) => 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),
|
||||
}
|
||||
|
||||
// 异步消息处理器(流式操作)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<number, { startTs: number; endTs: number; messageIds: number[] }>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -494,3 +494,66 @@ export async function executeRawSQL(sessionId: string, sql: string): Promise<SQL
|
||||
export async function getSchema(sessionId: string): Promise<TableSchema[]> {
|
||||
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<number> {
|
||||
return sendToWorker('generateSessions', { sessionId, gapThreshold })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话索引
|
||||
*/
|
||||
export async function clearSessions(sessionId: string): Promise<void> {
|
||||
return sendToWorker('clearSessions', { sessionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已生成会话索引
|
||||
*/
|
||||
export async function hasSessionIndex(sessionId: string): Promise<boolean> {
|
||||
return sendToWorker('hasSessionIndex', { sessionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话索引统计信息
|
||||
*/
|
||||
export async function getSessionStats(sessionId: string): Promise<SessionStats> {
|
||||
return sendToWorker('getSessionStats', { sessionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个聊天的会话切分阈值
|
||||
*/
|
||||
export async function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): Promise<void> {
|
||||
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<ChatSessionItem[]> {
|
||||
return sendToWorker('getSessions', { sessionId })
|
||||
}
|
||||
|
||||
Vendored
+25
@@ -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<number>
|
||||
hasIndex: (sessionId: string) => Promise<boolean>
|
||||
getStats: (sessionId: string) => Promise<SessionStats>
|
||||
clear: (sessionId: string) => Promise<boolean>
|
||||
updateGapThreshold: (sessionId: string, gapThreshold: number | null) => Promise<boolean>
|
||||
getSessions: (sessionId: string) => Promise<ChatSessionItem[]>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
@@ -451,6 +475,7 @@ declare global {
|
||||
agentApi: AgentApi
|
||||
cacheApi: CacheApi
|
||||
networkApi: NetworkApi
|
||||
sessionApi: SessionApi
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number> => {
|
||||
return ipcRenderer.invoke('session:generate', sessionId, gapThreshold)
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已生成会话索引
|
||||
*/
|
||||
hasIndex: (sessionId: string): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('session:hasIndex', sessionId)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会话索引统计信息
|
||||
*/
|
||||
getStats: (sessionId: string): Promise<SessionStats> => {
|
||||
return ipcRenderer.invoke('session:getStats', sessionId)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空会话索引
|
||||
*/
|
||||
clear: (sessionId: string): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('session:clear', sessionId)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新会话切分阈值
|
||||
*/
|
||||
updateGapThreshold: (sessionId: string, gapThreshold: number | null): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('session:updateGapThreshold', sessionId, gapThreshold)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会话列表(用于时间线导航)
|
||||
*/
|
||||
getSessions: (sessionId: string): Promise<ChatSessionItem[]> => {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 会话索引生成弹窗组件
|
||||
* 自动检测索引状态,未生成时通过 v-model 自动弹出
|
||||
* 使用 v-model 控制显示状态
|
||||
*/
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
/** 弹窗打开状态(v-model) */
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 更新 v-model */
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
/** 生成完成 */
|
||||
(e: 'generated', sessionCount: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 状态
|
||||
const hasIndex = ref(false)
|
||||
const sessionCount = ref(0)
|
||||
const isGenerating = ref(false)
|
||||
const isLoading = ref(true)
|
||||
// 是否是强制模式(未生成索引时自动弹出的情况)
|
||||
const forceMode = ref(false)
|
||||
|
||||
// 是否打开(双向绑定)
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue ?? false,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// 是否可以关闭弹窗
|
||||
const canClose = computed(() => {
|
||||
// 强制模式下不允许关闭
|
||||
return !forceMode.value
|
||||
})
|
||||
|
||||
// 检查会话索引状态并自动弹出
|
||||
async function checkAndAutoOpen() {
|
||||
if (!props.sessionId) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const stats = await window.sessionApi.getStats(props.sessionId)
|
||||
hasIndex.value = stats.hasIndex
|
||||
sessionCount.value = stats.sessionCount
|
||||
|
||||
// 如果未生成索引,自动弹出(强制模式)
|
||||
if (!hasIndex.value) {
|
||||
forceMode.value = true
|
||||
isOpen.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查会话索引失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新状态(不自动弹出,用于手动打开时)
|
||||
async function refreshStatus() {
|
||||
if (!props.sessionId) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const stats = await window.sessionApi.getStats(props.sessionId)
|
||||
hasIndex.value = stats.hasIndex
|
||||
sessionCount.value = stats.sessionCount
|
||||
} catch (error) {
|
||||
console.error('检查会话索引失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话索引
|
||||
async function generateSessionIndex() {
|
||||
if (!props.sessionId) return
|
||||
|
||||
isGenerating.value = true
|
||||
try {
|
||||
// 从 localStorage 获取全局阈值配置
|
||||
const savedThreshold = localStorage.getItem('sessionGapThreshold')
|
||||
const gapThreshold = savedThreshold ? parseInt(savedThreshold, 10) : 1800 // 默认30分钟
|
||||
|
||||
const count = await window.sessionApi.generate(props.sessionId, gapThreshold)
|
||||
hasIndex.value = true
|
||||
sessionCount.value = count
|
||||
emit('generated', count)
|
||||
|
||||
// 生成完成后自动关闭
|
||||
forceMode.value = false
|
||||
isOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('生成会话索引失败:', error)
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function close() {
|
||||
if (!canClose.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// 处理弹窗状态变化
|
||||
function handleOpenChange(value: boolean) {
|
||||
if (!value && !canClose.value) {
|
||||
// 强制模式下不允许关闭
|
||||
return
|
||||
}
|
||||
|
||||
isOpen.value = value
|
||||
|
||||
// 手动打开时(非强制模式),刷新状态
|
||||
if (value && !forceMode.value) {
|
||||
refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// sessionId 变化时重新检查并自动弹出
|
||||
watch(
|
||||
() => props.sessionId,
|
||||
() => {
|
||||
checkAndAutoOpen()
|
||||
}
|
||||
)
|
||||
|
||||
// 组件挂载时检查
|
||||
onMounted(() => {
|
||||
checkAndAutoOpen()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :open="isOpen" @update:open="handleOpenChange" :dismissible="canClose">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<!-- 头部 -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<UIcon name="i-heroicons-clock" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('sessionIndex.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('sessionIndex.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="canClose"
|
||||
icon="i-heroicons-x-mark"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<template v-else>
|
||||
<!-- 未生成索引 -->
|
||||
<div v-if="!hasIndex" class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex gap-3">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="h-5 w-5 shrink-0 text-amber-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{{ t('sessionIndex.notGenerated') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
{{ t('sessionIndex.notGeneratedHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('sessionIndex.whatIsIt') }}
|
||||
</h4>
|
||||
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-check" class="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
{{ t('sessionIndex.benefit1') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-check" class="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
{{ t('sessionIndex.benefit2') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<UIcon name="i-heroicons-check" class="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
{{ t('sessionIndex.benefit3') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已生成索引 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="h-5 w-5 shrink-0 text-green-500" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{{ t('sessionIndex.generated') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-green-700 dark:text-green-300">
|
||||
{{ t('sessionIndex.sessionCount', { count: sessionCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('sessionIndex.regenerateHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<UButton v-if="canClose" variant="ghost" @click="close">
|
||||
{{ t('sessionIndex.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:loading="isGenerating"
|
||||
@click="generateSessionIndex"
|
||||
>
|
||||
<UIcon v-if="!isGenerating" :name="hasIndex ? 'i-heroicons-arrow-path' : 'i-heroicons-sparkles'" class="mr-1 h-4 w-4" />
|
||||
{{ isGenerating ? t('sessionIndex.generating') : (hasIndex ? t('sessionIndex.regenerate') : t('sessionIndex.generate')) }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
@@ -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"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip :text="t('analysis.tooltip.sessionIndex')">
|
||||
<UButton
|
||||
icon="i-heroicons-clock"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showSessionIndexModal = true"
|
||||
/>
|
||||
</UTooltip>
|
||||
<CaptureButton />
|
||||
</template>
|
||||
<!-- Tabs -->
|
||||
@@ -375,6 +388,9 @@ onMounted(() => {
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<p class="text-gray-500">{{ t('analysis.groupChat.loadError') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 会话索引弹窗(内部自动检测并弹出) -->
|
||||
<SessionIndexModal v-if="currentSessionId" v-model="showSessionIndexModal" :session-id="currentSessionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip :text="t('analysis.tooltip.sessionIndex')">
|
||||
<UButton
|
||||
icon="i-heroicons-clock"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="showSessionIndexModal = true"
|
||||
/>
|
||||
</UTooltip>
|
||||
<CaptureButton />
|
||||
</template>
|
||||
<!-- Tabs -->
|
||||
@@ -330,6 +343,9 @@ onMounted(() => {
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<p class="text-gray-500">{{ t('analysis.privateChat.loadError') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 会话索引弹窗(内部自动检测并弹出) -->
|
||||
<SessionIndexModal v-if="currentSessionId" v-model="showSessionIndexModal" :session-id="currentSessionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// 传递诊断信息(如果有)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user