feat: 导入聊天记录支持生成会话索引

This commit is contained in:
digua
2026-01-11 15:00:35 +08:00
committed by digua
parent 8d524bdd8b
commit de3aef8f57
15 changed files with 1084 additions and 8 deletions
+25 -2
View File
@@ -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,
}
}
/**
+50 -4
View File
@@ -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
+76
View File
@@ -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 []
}
})
}
+15
View File
@@ -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),
}
// 异步消息处理器(流式操作)
+26 -2
View File
@@ -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)
+12
View File
@@ -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'
+342
View File
@@ -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()
}
}
+63
View File
@@ -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 })
}
+25
View File
@@ -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
}
}
+65
View File
@@ -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>
+16
View File
@@ -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>
+16
View File
@@ -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>
+11
View File
@@ -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 {
// 传递诊断信息(如果有)
+44
View File
@@ -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
}