diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index 42ceb62c..74de1897 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -996,6 +996,8 @@ export interface ChatSessionItem { endTs: number messageCount: number firstMessageId: number + summary?: string | null + summaryMessageCount?: number | null } /** diff --git a/packages/core/src/query/__tests__/session-index.test.ts b/packages/core/src/query/__tests__/session-index.test.ts index 758182e0..d224c5c8 100644 --- a/packages/core/src/query/__tests__/session-index.test.ts +++ b/packages/core/src/query/__tests__/session-index.test.ts @@ -18,6 +18,7 @@ import { getChatSessionList, getSegmentSummary, saveSegmentSummary, + loadSegmentMessages, updateSessionGapThreshold, clearSessionIndex, generateSessionIndex, @@ -85,7 +86,8 @@ function createSqliteDb(): TestSqliteDb { end_ts INTEGER NOT NULL, message_count INTEGER NOT NULL, is_manual INTEGER DEFAULT 0, - summary TEXT + summary TEXT, + summary_message_count INTEGER ); CREATE TABLE message_context ( message_id INTEGER NOT NULL, @@ -276,6 +278,53 @@ describe('getSegmentSummary / saveSegmentSummary', () => { saveSegmentSummary(db, 1, 'Test summary') assert.equal(getSegmentSummary(db, 1), 'Test summary') }) + + it('keeps an appended summary visible but treats it as stale', () => { + const db = createSqliteDb() + seedMessages(db, [{ id: 1, ts: 1000 }]) + generateSessionIndex(db, 2000) + saveSegmentSummary(db, 1, 'Summary before append') + + assert.equal(getSegmentSummary(db, 1), 'Summary before append') + seedMessages(db, [{ id: 2, ts: 1500 }]) + generateIncrementalSessionIndex(db, 2000) + + assert.equal(getSegmentSummary(db, 1), null) + assert.deepEqual(getChatSessionList(db)[0], { + id: 1, + startTs: 1000, + endTs: 1500, + messageCount: 2, + summary: 'Summary before append', + summaryMessageCount: 1, + firstMessageId: 1, + }) + }) +}) + +describe('loadSegmentMessages', () => { + it('loads every message for summary generation instead of truncating at 500', () => { + const db = createSqliteDb() + db.exec(` + ALTER TABLE message ADD COLUMN sender_id INTEGER; + ALTER TABLE message ADD COLUMN content TEXT; + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT NOT NULL, + account_name TEXT, + group_nickname TEXT + ); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'alice', 'Alice'); + `) + + const insertMessage = db.prepare('INSERT INTO message (id, ts, sender_id, content) VALUES (?, ?, 1, ?)') + for (let id = 1; id <= 600; id++) insertMessage.run(id, 1000 + id, `message-${id}`) + generateSessionIndex(db, 2000) + + const messages = loadSegmentMessages(db, 1) + assert.equal(messages?.length, 600) + assert.equal(messages?.[599]?.content, 'message-600') + }) }) describe('updateSessionGapThreshold', () => { diff --git a/packages/core/src/query/__tests__/session-queries.test.ts b/packages/core/src/query/__tests__/session-queries.test.ts index 0c3b8ad8..c7392725 100644 --- a/packages/core/src/query/__tests__/session-queries.test.ts +++ b/packages/core/src/query/__tests__/session-queries.test.ts @@ -56,10 +56,10 @@ function createSessionDb(): SqliteTestAdapter { (1, 1, 100, 0, 'one', 'pm-1'), (2, 1, 300, 0, 'three', 'pm-3'), (3, 2, 200, 0, 'two', NULL); - INSERT INTO segment (start_ts, end_ts, message_count, summary) VALUES - (100, 150, 1, 'summary one'), - (200, 250, 1, NULL), - (300, 350, 1, 'summary two'); + INSERT INTO segment (start_ts, end_ts, message_count, summary, summary_message_count) VALUES + (100, 150, 1, 'summary one', 1), + (200, 250, 1, NULL, NULL), + (300, 350, 1, 'summary two', 1); `) return db } diff --git a/packages/core/src/query/session-queries.ts b/packages/core/src/query/session-queries.ts index ed3482c5..b73473f0 100644 --- a/packages/core/src/query/session-queries.ts +++ b/packages/core/src/query/session-queries.ts @@ -95,9 +95,14 @@ export function getSessionInfo(db: DatabaseAdapter): CoreSessionInfo | null { */ export function getSummaryCount(db: DatabaseAdapter): number { if (!hasTable(db, 'segment')) return 0 - const row = db.prepare("SELECT COUNT(*) as count FROM segment WHERE summary IS NOT NULL AND summary != ''").get() as - | { count: number } - | undefined + const row = db + .prepare( + `SELECT COUNT(*) as count + FROM segment + WHERE summary IS NOT NULL AND summary != '' + AND summary_message_count = message_count` + ) + .get() as { count: number } | undefined return row?.count ?? 0 } @@ -320,6 +325,7 @@ export function getSegmentSummaries( cs.message_count as messageCount, cs.summary FROM segment cs WHERE cs.summary IS NOT NULL AND cs.summary != '' + AND cs.summary_message_count = cs.message_count ` const params: unknown[] = [] @@ -388,6 +394,8 @@ export interface ChatSessionItem { messageCount: number firstMessageId: number summary?: string | null + /** Number of messages covered when the summary was generated. Null means legacy/unknown coverage. */ + summaryMessageCount?: number | null } export interface SessionIndexStats { @@ -449,6 +457,7 @@ export function getSessionsByTimeRange(db: DatabaseAdapter, startTs: number, end `SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary, + summary_message_count as summaryMessageCount, (SELECT mc.message_id FROM message_context mc JOIN message first_msg ON first_msg.id = mc.message_id WHERE mc.segment_id = cs.id ORDER BY first_msg.ts ASC, first_msg.id ASC LIMIT 1) as firstMessageId @@ -473,6 +482,7 @@ export function getRecentChatSessions(db: DatabaseAdapter, limit: number): ChatS `SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount, summary, + summary_message_count as summaryMessageCount, (SELECT mc.message_id FROM message_context mc JOIN message first_msg ON first_msg.id = mc.message_id WHERE mc.segment_id = cs.id ORDER BY first_msg.ts ASC, first_msg.id ASC LIMIT 1) as firstMessageId @@ -500,6 +510,7 @@ export function getChatSessionList(db: DatabaseAdapter): ChatSessionItem[] { cs.end_ts as endTs, cs.message_count as messageCount, cs.summary, + cs.summary_message_count as summaryMessageCount, (SELECT mc.message_id FROM message_context mc JOIN message first_msg ON first_msg.id = mc.message_id WHERE mc.segment_id = cs.id ORDER BY first_msg.ts ASC, first_msg.id ASC LIMIT 1) as firstMessageId @@ -518,22 +529,24 @@ export function getChatSessionList(db: DatabaseAdapter): ChatSessionItem[] { export function loadSegmentMessages( db: DatabaseAdapter, segmentId: number, - limit: number = 500 + limit?: number ): Array<{ senderName: string; content: string | null }> | null { try { - return db - .prepare( - `SELECT + const limitClause = limit === undefined ? '' : '\n LIMIT ?' + const statement = db.prepare( + `SELECT COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName, m.content FROM message_context mc JOIN message m ON m.id = mc.message_id JOIN member mb ON mb.id = m.sender_id WHERE mc.segment_id = ? - ORDER BY m.ts ASC - LIMIT ?` - ) - .all(segmentId, limit) as unknown as Array<{ senderName: string; content: string | null }> + ORDER BY m.ts ASC, m.id ASC${limitClause}` + ) + return statement.all(...(limit === undefined ? [segmentId] : [segmentId, limit])) as unknown as Array<{ + senderName: string + content: string | null + }> } catch { return null } @@ -544,10 +557,15 @@ export function loadSegmentMessages( */ export function getSegmentSummary(db: DatabaseAdapter, segmentId: number): string | null { try { - const row = db.prepare('SELECT summary FROM segment WHERE id = ?').get(segmentId) as - | { summary: string | null } + const row = db + .prepare( + 'SELECT summary, summary_message_count as summaryMessageCount, message_count as messageCount FROM segment WHERE id = ?' + ) + .get(segmentId) as + | { summary: string | null; summaryMessageCount: number | null; messageCount: number } | undefined - return row?.summary ?? null + if (!row?.summary || row.summaryMessageCount !== row.messageCount) return null + return row.summary } catch { return null } @@ -557,7 +575,10 @@ export function getSegmentSummary(db: DatabaseAdapter, segmentId: number): strin * Save summary text for a chat session. */ export function saveSegmentSummary(db: DatabaseAdapter, segmentId: number, summary: string): void { - db.prepare('UPDATE segment SET summary = ? WHERE id = ?').run(summary, segmentId) + db.prepare('UPDATE segment SET summary = ?, summary_message_count = message_count WHERE id = ?').run( + summary, + segmentId + ) } /** diff --git a/packages/core/src/schema/tables.ts b/packages/core/src/schema/tables.ts index 4122b6cd..ffb10d84 100644 --- a/packages/core/src/schema/tables.ts +++ b/packages/core/src/schema/tables.ts @@ -4,11 +4,11 @@ * 所有 CREATE TABLE / INDEX 语句的单一事实来源。 * 新建数据库时使用完整 Schema,现有数据库通过迁移脚本演进。 * - * 当前 Schema 版本:9 + * 当前 Schema 版本:10 */ /** 当前 Schema 版本(最新迁移的版本号) */ -export const CURRENT_SCHEMA_VERSION = 9 +export const CURRENT_SCHEMA_VERSION = 10 /** * Table DDL only (no indexes). Used by bulk-import workflows that defer @@ -66,7 +66,8 @@ export const CHAT_DB_TABLES = ` end_ts INTEGER NOT NULL, message_count INTEGER DEFAULT 0, is_manual INTEGER DEFAULT 0, - summary TEXT + summary TEXT, + summary_message_count INTEGER ); CREATE TABLE IF NOT EXISTS message_context ( diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index 19f84090..0efd58db 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -244,6 +244,61 @@ test('open safely upgrades a v8 session that has no FTS table', () => { manager.closeAll() }) +test('open adds summary coverage metadata without trusting legacy summaries', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'v9-summary-coverage.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 9 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('V9 Summary', 'qq', 'group', 1000, 9); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL + ); + + CREATE TABLE segment ( + 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 + ); + INSERT INTO segment (start_ts, end_ts, message_count, summary) + VALUES (1000, 1100, 2, 'legacy summary'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('v9-summary-coverage') + assert.ok(db) + + assert.deepEqual(db.prepare('SELECT schema_version FROM meta').get(), { schema_version: CURRENT_SCHEMA_VERSION }) + assert.deepEqual(db.prepare('SELECT summary, summary_message_count FROM segment').get(), { + summary: 'legacy summary', + summary_message_count: null, + }) + + manager.closeAll() +}) + test('open migrates v7 databases to include analysis tool indexes', () => { const root = makeTempDir() const dbDir = path.join(root, 'data', 'databases') diff --git a/packages/node-runtime/src/migrations/chat-db-migrations.ts b/packages/node-runtime/src/migrations/chat-db-migrations.ts index a3e800b2..fbf5b27f 100644 --- a/packages/node-runtime/src/migrations/chat-db-migrations.ts +++ b/packages/node-runtime/src/migrations/chat-db-migrations.ts @@ -54,6 +54,14 @@ export function getChatDbMigrations(): CoreMigration[] { return tableInfo.some((col) => col.name === columnName) } + const hasTable = (db: DatabaseAdapter, tableName: string): boolean => { + return Boolean( + db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) as + | Record + | undefined + ) + } + const addColumnIfMissing = (db: DatabaseAdapter, tableName: string, columnName: string, definition: string): void => { if (!hasColumn(db, tableName, columnName)) { db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`) @@ -372,5 +380,14 @@ export function getChatDbMigrations(): CoreMigration[] { db.exec('DROP TABLE IF EXISTS message_fts') }, }, + { + version: 10, + description: 'Track message coverage for generated segment summaries', + up: (db: DatabaseAdapter) => { + // Existing summaries may have been truncated or preserved after incremental imports. + // Keep their text for users to review, but leave coverage unknown so the new runtime marks them stale. + if (hasTable(db, 'segment')) addColumnIfMissing(db, 'segment', 'summary_message_count', 'INTEGER') + }, + }, ] } diff --git a/packages/node-runtime/src/services/summary-service.ts b/packages/node-runtime/src/services/summary-service.ts index 38abea9e..0fed1cc7 100644 --- a/packages/node-runtime/src/services/summary-service.ts +++ b/packages/node-runtime/src/services/summary-service.ts @@ -5,7 +5,7 @@ * and summary generation loop — shared across CLI Web and Electron. */ -import { getSegmentSummary, saveSegmentSummary, getChatSessionList, getSegmentMessages } from '@openchatlab/core' +import { getSegmentSummary, saveSegmentSummary, getChatSessionList, loadSegmentMessages } from '@openchatlab/core' import type { DatabaseAdapter } from '@openchatlab/core' import { generateSessionSummary, @@ -29,10 +29,8 @@ export interface SummaryServiceDeps { function buildSummaryDeps(db: DatabaseAdapter, llmConfig: LlmConfig, deps: SummaryServiceDeps): SummaryDeps { const piModel = deps.buildPiModel(llmConfig) return { - loadMessages(segmentId, limit = 500) { - const data = getSegmentMessages(db, segmentId, limit) - if (!data) return null - return data.messages.map((m) => ({ senderName: m.senderName, content: m.content })) + loadMessages(segmentId, limit) { + return loadSegmentMessages(db, segmentId, limit) }, saveSummary(segmentId, summary) { saveSegmentSummary(db, segmentId, summary) @@ -109,10 +107,8 @@ export function checkCanGenerate( ): Record { const db = adapter.ensureReadonly(sessionId) const deps: Pick = { - loadMessages(segmentId, limit = 500) { - const data = getSegmentMessages(db, segmentId, limit) - if (!data) return null - return data.messages.map((m) => ({ senderName: m.senderName, content: m.content })) + loadMessages(segmentId, limit) { + return loadSegmentMessages(db, segmentId, limit) }, t: (key: string) => key, } diff --git a/src/components/common/ChatRecord/BatchSummaryModal.vue b/src/components/common/ChatRecord/BatchSummaryModal.vue index 7defed1e..a19e1910 100644 --- a/src/components/common/ChatRecord/BatchSummaryModal.vue +++ b/src/components/common/ChatRecord/BatchSummaryModal.vue @@ -47,6 +47,7 @@ interface SessionItem { messageCount: number // 与 sessionApi 返回值保持一致,历史数据里 summary 可能不存在。 summary?: string | null + summaryMessageCount?: number | null } const sessions = ref([]) const isLoading = ref(false) @@ -122,10 +123,18 @@ const timeRange = computed(() => { const canGenerateMap = ref>({}) const isChecking = ref(false) +function isSummaryStale(session: SessionItem): boolean { + return Boolean(session.summary) && session.summaryMessageCount !== session.messageCount +} + +function hasFreshSummary(session: SessionItem): boolean { + return Boolean(session.summary) && !isSummaryStale(session) +} + // 待生成的会话(排除已有摘要的 + 消息太少的) const pendingSessions = computed(() => { return sessions.value.filter((s) => { - if (s.summary) return false + if (hasFreshSummary(s)) return false const checkResult = canGenerateMap.value[s.id] return checkResult?.canGenerate !== false }) @@ -133,13 +142,17 @@ const pendingSessions = computed(() => { // 已有摘要的会话数 const existingSummaryCount = computed(() => { - return sessions.value.filter((s) => s.summary).length + return sessions.value.filter(hasFreshSummary).length +}) + +const staleSummaryCount = computed(() => { + return sessions.value.filter(isSummaryStale).length }) // 消息数量太少的会话数(无摘要但无法生成) const tooFewMessagesCount = computed(() => { return sessions.value.filter((s) => { - if (s.summary) return false + if (hasFreshSummary(s)) return false const checkResult = canGenerateMap.value[s.id] return checkResult?.canGenerate === false }).length @@ -199,7 +212,7 @@ async function fetchSessions() { // 批量检查会话是否可以生成摘要 async function checkCanGenerate() { - const noSummaryIds = sessions.value.filter((s) => !s.summary).map((s) => s.id) + const noSummaryIds = sessions.value.filter((s) => !hasFreshSummary(s)).map((s) => s.id) if (noSummaryIds.length === 0) return isChecking.value = true @@ -291,6 +304,7 @@ async function startGenerate() { const idx = sessions.value.findIndex((s) => s.id === session.id) if (idx !== -1) { sessions.value[idx].summary = result.summary || '' + sessions.value[idx].summaryMessageCount = sessions.value[idx].messageCount } } else if (result.error && isTooFewMessagesError(result.error)) { // 消息数量太少:标记为跳过 @@ -441,7 +455,7 @@ function close() {

{{ t('records.batchSummary.found', '找到') }} {{ sessions.length }} {{ t('records.batchSummary.sessionsUnit', '个会话') }} -