diff --git a/apps/cli/src/ai/agent-stream-runner.test.ts b/apps/cli/src/ai/agent-stream-runner.test.ts index 6958191e..f9d974c7 100644 --- a/apps/cli/src/ai/agent-stream-runner.test.ts +++ b/apps/cli/src/ai/agent-stream-runner.test.ts @@ -40,6 +40,17 @@ describe('CLI chart capability tool filtering', () => { assert.ok(!toolNames.includes('execute_sql')) }) + it('accepts legacy session tool names in assistant allowlists', () => { + const allowedToolSet = getAllowedToolSet(false, ['get_session_summaries']) + + assert.ok(allowedToolSet instanceof Set) + + const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name) + + assert.ok(toolNames.includes('get_segment_summaries')) + assert.ok(!toolNames.includes('get_session_summaries')) + }) + it('preserves an empty assistant allowlist instead of treating it as unrestricted', () => { const allowedToolSet = getAllowedToolSet(false, []) diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts index e9e92039..ed9a0cfb 100644 --- a/apps/cli/src/ai/agent-stream-runner.ts +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -14,7 +14,7 @@ import { getSkillConfigWithBuiltinChart, resolveChartRuntimeForRequest, } from '@openchatlab/node-runtime' -import { getChatOverview } from '@openchatlab/core' +import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core' import type { DataSnapshot } from '@openchatlab/node-runtime' import type { AgentStreamRequest } from '@openchatlab/http-routes' import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' @@ -54,9 +54,9 @@ export function getAllowedToolSet( allowedBuiltinTools?: readonly string[] ): Set | null { if (isChartCapability) { - return new Set(allowedBuiltinTools) + return new Set(normalizeBuiltinToolNames(allowedBuiltinTools ?? [])) } - return allowedBuiltinTools === undefined ? null : new Set(allowedBuiltinTools) + return allowedBuiltinTools === undefined ? null : new Set(normalizeBuiltinToolNames(allowedBuiltinTools)) } export function createCliRunAgentStream( diff --git a/apps/cli/src/ai/chat-command.test.ts b/apps/cli/src/ai/chat-command.test.ts index ca6b1a5b..c2b91a1c 100644 --- a/apps/cli/src/ai/chat-command.test.ts +++ b/apps/cli/src/ai/chat-command.test.ts @@ -1,8 +1,9 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import { Readable } from 'node:stream' import { Writable } from 'node:stream' import type { AIChatManager, DatabaseManager } from '@openchatlab/node-runtime' -import { resolveAIChatTarget, runChatTurn } from './chat-command' +import { resolveAIChatTarget, runChatCommand, runChatTurn } from './chat-command' function createDbManager(sessionIds: string[]): DatabaseManager { return { @@ -11,9 +12,11 @@ function createDbManager(sessionIds: string[]): DatabaseManager { } as unknown as DatabaseManager } -function createAIChatManager(existing: Array<{ id: string; sessionId: string }> = []): AIChatManager { +function createAIChatManager( + existing: Array<{ id: string; sessionId: string; assistantId?: string }> = [] +): AIChatManager { const chats = new Map( - existing.map((chat) => [chat.id, { ...chat, title: null, assistantId: 'general_cn' }]) + existing.map((chat) => [chat.id, { ...chat, title: null, assistantId: chat.assistantId ?? 'general_cn' }]) ) const messages: Array<{ aiChatId: string; role: string; content: string }> = [] @@ -35,9 +38,12 @@ function createAIChatManager(existing: Array<{ id: string; sessionId: string }> class MemoryWritable extends Writable { chunks: string[] = [] + onChunk?: (text: string) => void _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { - this.chunks.push(String(chunk)) + const text = String(chunk) + this.chunks.push(text) + this.onChunk?.(text) callback() } @@ -46,6 +52,28 @@ class MemoryWritable extends Writable { } } +class PromptDrivenReadable extends Readable { + private nextLines: string[] + + constructor(lines: string[]) { + super() + this.nextLines = lines + } + + _read(): void { + // Input is pushed when the CLI writes a prompt. + } + + pushNext(): void { + const line = this.nextLines.shift() + if (line === undefined) { + this.push(null) + return + } + this.push(`${line}\n`) + } +} + describe('resolveAIChatTarget', () => { it('creates a new AI chat for an explicit session id', () => { const target = resolveAIChatTarget( @@ -67,7 +95,12 @@ describe('resolveAIChatTarget', () => { } ) - assert.deepEqual(target, { sessionId: 'session-1', aiChatId: 'ai-chat-1', created: false }) + assert.deepEqual(target, { + sessionId: 'session-1', + aiChatId: 'ai-chat-1', + assistantId: 'general_cn', + created: false, + }) }) it('rejects mismatched explicit session id and aiChatId', () => { @@ -89,6 +122,7 @@ describe('runChatTurn', () => { it('collects streamed answer and persists user and assistant messages', async () => { const stdout = new MemoryWritable() const aiChatManager = createAIChatManager() + let streamedAssistantId: string | undefined const result = await runChatTurn( { sessionId: 'session-1', question: 'hello', json: true }, { @@ -96,7 +130,8 @@ describe('runChatTurn', () => { pathProvider: {} as never, aiChatManager, stdout, - createRunAgentStream: () => async (_params, onEvent) => { + createRunAgentStream: () => async (params, onEvent) => { + streamedAssistantId = params.assistantId onEvent({ type: 'content', content: 'hi' }) onEvent({ type: 'done', @@ -111,10 +146,101 @@ describe('runChatTurn', () => { assert.equal(result.aiChatId, 'ai_chat_1') assert.equal(result.answer, 'hi') assert.equal(result.usage.tokenUsage?.totalTokens, 2) + assert.equal(streamedAssistantId, 'general_cn') assert.equal(stdout.text(), '') assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ { aiChatId: 'ai_chat_1', role: 'user', content: 'hello' }, { aiChatId: 'ai_chat_1', role: 'assistant', content: 'hi' }, ]) }) + + it('fails the turn and does not persist messages when the agent stream reports an error', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + + await assert.rejects( + () => + runChatTurn( + { sessionId: 'session-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ), + /LLM service not configured/ + ) + + assert.equal(stdout.text(), '') + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, []) + }) + + it('passes the resolved AI chat assistant id into the agent stream', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager([ + { id: 'ai-chat-1', sessionId: 'session-1', assistantId: 'custom_assistant' }, + ]) + let streamedAssistantId: string | undefined + + await runChatTurn( + { aiChatId: 'ai-chat-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (params, onEvent) => { + streamedAssistantId = params.assistantId + onEvent({ type: 'content', content: 'hi' }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + + assert.equal(streamedAssistantId, 'custom_assistant') + }) +}) + +describe('runChatCommand', () => { + it('keeps interactive mode alive after a single failed turn', async () => { + const stdout = new MemoryWritable() + const stderr = new MemoryWritable() + const stdin = new PromptDrivenReadable(['fail', 'recover', 'exit']) + const aiChatManager = createAIChatManager() + stdout.onChunk = (text) => { + if (text.includes('chatlab> ')) stdin.pushNext() + } + + const command = runChatCommand( + { sessionId: 'session-1' }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + stderr, + stdin, + createRunAgentStream: () => async (params, onEvent) => { + if (params.userMessage === 'fail') { + throw new Error('temporary failure') + } + onEvent({ type: 'content', content: 'recovered' }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + await command + + assert.match(stderr.text(), /temporary failure/) + assert.match(stdout.text(), /recovered/) + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ + { aiChatId: 'ai_chat_2', role: 'user', content: 'recover' }, + { aiChatId: 'ai_chat_2', role: 'assistant', content: 'recovered' }, + ]) + }) }) diff --git a/apps/cli/src/ai/chat-command.ts b/apps/cli/src/ai/chat-command.ts index 750fb917..81e02525 100644 --- a/apps/cli/src/ai/chat-command.ts +++ b/apps/cli/src/ai/chat-command.ts @@ -26,6 +26,7 @@ export interface ChatCommandDeps { export interface ResolvedAIChatTarget { sessionId: string aiChatId: string + assistantId: string created: boolean } @@ -50,6 +51,21 @@ function buildTitle(question?: string): string { return trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed } +function createAgentStreamError(error: unknown): Error { + if (error instanceof Error) return error + if (typeof error === 'string' && error) return new Error(error) + if (typeof error === 'object' && error !== null) { + const record = error as { name?: unknown; message?: unknown; stack?: unknown } + const streamError = new Error( + typeof record.message === 'string' && record.message ? record.message : 'Agent stream failed' + ) + if (typeof record.name === 'string' && record.name) streamError.name = record.name + if (typeof record.stack === 'string' && record.stack) streamError.stack = record.stack + return streamError + } + return new Error('Agent stream failed') +} + function resolveSingleSessionId(dbManager: DatabaseManager): string { const sessionIds = dbManager.listSessionIds() if (sessionIds.length === 1) return sessionIds[0]! @@ -79,13 +95,13 @@ export function resolveAIChatTarget( throw new Error(`AI chat ${options.aiChatId} belongs to session ${aiChat.sessionId}, not ${options.sessionId}`) } assertSessionExists(deps.dbManager, aiChat.sessionId) - return { sessionId: aiChat.sessionId, aiChatId: aiChat.id, created: false } + return { sessionId: aiChat.sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: false } } const sessionId = options.sessionId ?? resolveSingleSessionId(deps.dbManager) assertSessionExists(deps.dbManager, sessionId) const aiChat = deps.aiChatManager.createAIChat(sessionId, buildTitle(options.question), 'general_cn') - return { sessionId, aiChatId: aiChat.id, created: true } + return { sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: true } } export async function runChatTurn( @@ -98,6 +114,7 @@ export async function runChatTurn( const startedAt = Date.now() let answer = '' let tokenUsage: TokenUsageData | null = null + let streamError: Error | null = null const runAgentStream = (deps.createRunAgentStream ?? createCliRunAgentStream)(deps.dbManager, deps.aiChatManager) @@ -106,10 +123,15 @@ export async function runChatTurn( userMessage: options.question, sessionId: target.sessionId, aiChatId: target.aiChatId, + assistantId: target.assistantId, chatType: 'group', locale: options.locale ?? 'zh-CN', }, (chunk: AgentStreamChunk) => { + if (chunk.type === 'error') { + streamError = createAgentStreamError(chunk.error) + return + } if (chunk.type === 'content' && chunk.content) { answer += chunk.content if (!options.json && options.stream !== false) write(stdout, chunk.content) @@ -121,6 +143,8 @@ export async function runChatTurn( new AbortController().signal ) + if (streamError) throw streamError + if (!options.json && options.stream !== false && answer && !answer.endsWith('\n')) { write(stdout, '\n') } @@ -172,12 +196,13 @@ export async function runChatCommand(options: ChatCommandOptions, deps: ChatComm while (true) { const question = (await rl.question('chatlab> ')).trim() if (!question || question === 'exit' || question === 'quit') break - const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps) - aiChatId = result.aiChatId + try { + const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps) + aiChatId = result.aiChatId + } catch (error) { + write(stderr, `${error instanceof Error ? error.message : String(error)}\n`) + } } - } catch (error) { - write(stderr, `${error instanceof Error ? error.message : String(error)}\n`) - throw error } finally { rl.close() } diff --git a/apps/desktop/main/ai/tools/tool-filter.test.ts b/apps/desktop/main/ai/tools/tool-filter.test.ts index 7830c379..430bc9e6 100644 --- a/apps/desktop/main/ai/tools/tool-filter.test.ts +++ b/apps/desktop/main/ai/tools/tool-filter.test.ts @@ -13,4 +13,8 @@ describe('Electron analysis tool filtering', () => { assert.equal(isAnalysisToolAllowed('keyword_frequency', ['keyword_frequency']), true) assert.equal(isAnalysisToolAllowed('execute_sql', ['keyword_frequency']), false) }) + + it('accepts legacy session tool names in assistant allowlists', () => { + assert.equal(isAnalysisToolAllowed('get_segment_summaries', ['get_session_summaries']), true) + }) }) diff --git a/apps/desktop/main/ai/tools/tool-filter.ts b/apps/desktop/main/ai/tools/tool-filter.ts index 79407371..edec5af7 100644 --- a/apps/desktop/main/ai/tools/tool-filter.ts +++ b/apps/desktop/main/ai/tools/tool-filter.ts @@ -1,3 +1,5 @@ +import { normalizeBuiltinToolNames } from '@openchatlab/core' + export function isAnalysisToolAllowed(toolName: string, allowedTools?: readonly string[] | null): boolean { - return !!allowedTools?.includes(toolName) + return !!allowedTools && normalizeBuiltinToolNames(allowedTools).includes(toolName) } diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts index c6d4f0bc..d3c3196a 100644 --- a/packages/core/src/ai/index.ts +++ b/packages/core/src/ai/index.ts @@ -4,7 +4,7 @@ // 内置工具目录 export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog' -export { BUILTIN_TOOL_CATALOG } from './tool-catalog' +export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './tool-catalog' export { CHART_CAPABILITY_SKILL_ID } from './chart-capability' // LLM 模型系统类型 diff --git a/packages/core/src/ai/tool-catalog.ts b/packages/core/src/ai/tool-catalog.ts index ca1c4520..8f07240c 100644 --- a/packages/core/src/ai/tool-catalog.ts +++ b/packages/core/src/ai/tool-catalog.ts @@ -12,6 +12,20 @@ export interface BuiltinToolCatalogEntry { category: ToolCategory } +const LEGACY_BUILTIN_TOOL_NAME_ALIASES: Record = { + search_sessions: 'search_segments', + get_session_messages: 'get_segment_messages', + get_session_summaries: 'get_segment_summaries', +} + +export function normalizeBuiltinToolName(toolName: string): string { + return LEGACY_BUILTIN_TOOL_NAME_ALIASES[toolName] ?? toolName +} + +export function normalizeBuiltinToolNames(toolNames: readonly string[]): string[] { + return Array.from(new Set(toolNames.map(normalizeBuiltinToolName))) +} + export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [ // Core 工具 { name: 'get_chat_overview', category: 'core' }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8fd62cf9..7dc618d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -230,7 +230,7 @@ export type { // AI(内置工具目录、LLM 模型系统等静态数据) export type { ToolCategory, BuiltinToolCatalogEntry } from './ai' -export { BUILTIN_TOOL_CATALOG } from './ai' +export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './ai' export type { ProviderKind, ProviderDefinition, diff --git a/packages/core/src/query/advanced/relationship.ts b/packages/core/src/query/advanced/relationship.ts index 4b55f1de..ae7b7a33 100644 --- a/packages/core/src/query/advanced/relationship.ts +++ b/packages/core/src/query/advanced/relationship.ts @@ -102,9 +102,9 @@ export function getRelationshipStats( const whereClause = timeConditions.length > 0 ? `WHERE ${timeConditions.join(' AND ')}` : '' - const sessionRows = db + const segmentRows = db .prepare( - `SELECT cs.id AS session_id, cs.start_ts, cs.end_ts, + `SELECT cs.id AS segment_id, cs.start_ts, cs.end_ts, (SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id WHERE mc.segment_id = cs.id ORDER BY m.ts ASC, m.id ASC LIMIT 1) AS initiator_id, (SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id @@ -112,7 +112,7 @@ export function getRelationshipStats( FROM segment cs ${whereClause} ORDER BY cs.start_ts ASC` ) .all(...params) as Array<{ - session_id: number + segment_id: number start_ts: number end_ts: number initiator_id: number | null @@ -135,7 +135,7 @@ export function getRelationshipStats( let totalIceBreaks = 0 let prevEndTs: number | null = null - for (const row of sessionRows) { + for (const row of segmentRows) { const month = toLocalMonth(row.start_ts) if (!monthMap.has(month)) monthMap.set(month, { initiateMap: new Map(), closeMap: new Map(), totalSessions: 0 }) const ms = monthMap.get(month)! @@ -192,13 +192,13 @@ export function getRelationshipStats( } } - const sessionIdList = sessionRows.map((r) => r.session_id) - const msgStats = queryMessageLevelStats(db, sessionIdList, memberNames, perseveranceThreshold) + const segmentIdList = segmentRows.map((r) => r.segment_id) + const msgStats = queryMessageLevelStats(db, segmentIdList, memberNames, perseveranceThreshold) return { months, members, - totalSessions: sessionRows.length, + totalSessions: segmentRows.length, hasSessionIndex: true, iceBreakers, totalIceBreaks, @@ -209,7 +209,7 @@ export function getRelationshipStats( function queryMessageLevelStats( db: DatabaseAdapter, - sessionIds: number[], + segmentIds: number[], memberNames: Map, perseveranceThreshold: number ): { @@ -226,7 +226,7 @@ function queryMessageLevelStats( monthlyResponseLatency: [], monthlyPerseverance: [], } - if (sessionIds.length === 0) return empty + if (segmentIds.length === 0) return empty const BATCH_SIZE = 500 const responseTotals = new Map() @@ -234,8 +234,8 @@ function queryMessageLevelStats( const monthlyRespMap = new Map>() const monthlyDtMap = new Map>() - for (let i = 0; i < sessionIds.length; i += BATCH_SIZE) { - const batch = sessionIds.slice(i, i + BATCH_SIZE) + for (let i = 0; i < segmentIds.length; i += BATCH_SIZE) { + const batch = segmentIds.slice(i, i + BATCH_SIZE) const placeholders = batch.map(() => '?').join(',') const latencyRows = db diff --git a/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts b/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts new file mode 100644 index 00000000..81b2d114 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parseAssistantFile } from '../assistant-parser' + +describe('parseAssistantFile', () => { + it('normalizes legacy session tool names in allowedBuiltinTools', () => { + const config = parseAssistantFile( + `--- +id: legacy_tools +name: Legacy Tools +allowedBuiltinTools: + - search_sessions + - get_session_messages + - get_session_summaries + - keyword_frequency +--- +Use selected tools.`, + 'legacy_tools.md' + ) + + assert.ok(config) + assert.deepEqual(config.allowedBuiltinTools, [ + 'search_segments', + 'get_segment_messages', + 'get_segment_summaries', + 'keyword_frequency', + ]) + }) +}) diff --git a/packages/node-runtime/src/ai/assistant-parser.ts b/packages/node-runtime/src/ai/assistant-parser.ts index 20b618e8..daf2517f 100644 --- a/packages/node-runtime/src/ai/assistant-parser.ts +++ b/packages/node-runtime/src/ai/assistant-parser.ts @@ -4,6 +4,7 @@ import * as path from 'path' import matter from 'gray-matter' +import { normalizeBuiltinToolNames } from '@openchatlab/core' import type { AssistantConfig } from './types' export function parseAssistantFile(content: string, filePath: string): AssistantConfig | null { @@ -19,7 +20,7 @@ export function parseAssistantFile(content: string, filePath: string): Assistant name, systemPrompt: body.trim(), presetQuestions: parseStringArray(fm.presetQuestions), - allowedBuiltinTools: parseStringArray(fm.allowedBuiltinTools), + allowedBuiltinTools: normalizeBuiltinToolNames(parseStringArray(fm.allowedBuiltinTools)), builtinId: typeof fm.builtinId === 'string' ? fm.builtinId : undefined, applicableChatTypes: parseChatTypes(fm.applicableChatTypes), supportedLocales: parseStringArray(fm.supportedLocales), @@ -38,7 +39,7 @@ export function serializeAssistant(config: AssistantConfig): string { if (config.builtinId) fm.builtinId = config.builtinId if (config.applicableChatTypes?.length) fm.applicableChatTypes = config.applicableChatTypes if (config.supportedLocales?.length) fm.supportedLocales = config.supportedLocales - if (config.allowedBuiltinTools?.length) fm.allowedBuiltinTools = config.allowedBuiltinTools + if (config.allowedBuiltinTools?.length) fm.allowedBuiltinTools = normalizeBuiltinToolNames(config.allowedBuiltinTools) if (config.presetQuestions?.length) fm.presetQuestions = config.presetQuestions return matter.stringify(`\n${config.systemPrompt}\n`, fm) diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index d8e97db3..7f093076 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -131,7 +131,7 @@ test('open backfills FTS index when migrating legacy sessions', () => { assert.ok(db) const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } - assert.equal(version.schema_version, 5) + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) const ftsCount = db.prepare('SELECT COUNT(*) as total FROM message_fts').get() as { total: number } assert.equal(ftsCount.total, 1) @@ -144,6 +144,163 @@ test('open backfills FTS index when migrating legacy sessions', () => { manager.closeAll() }) +test('open migrates v2 chat_session schema to current segment schema', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'v2-segment-schema.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 2 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('V2 Segment Schema', 'qq', 'group', 1000, 2); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT, + platform_message_id TEXT DEFAULT NULL + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello v2'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding }) + const db = manager.open('v2-segment-schema') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const segmentTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'segment'").get() + const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get() + assert.ok(segmentTable) + assert.equal(legacyTable, undefined) + + const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }> + assert.equal( + contextColumns.some((col) => col.name === 'segment_id'), + true + ) + assert.equal( + contextColumns.some((col) => col.name === 'session_id'), + false + ) + + manager.closeAll() +}) + +test('open migrates legacy chat_session rows into segment after v5 creates segment table', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy-segments.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 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Legacy Segment Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello segment'); + + CREATE TABLE 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 + ); + INSERT INTO chat_session (id, start_ts, end_ts, message_count, is_manual, summary) + VALUES (7, 1000, 1010, 1, 0, 'legacy summary'); + + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ); + INSERT INTO message_context (message_id, session_id, topic_id) VALUES (1, 7, 3); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding }) + const db = manager.open('legacy-segments') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get() + assert.equal(legacyTable, undefined) + + const segment = db.prepare('SELECT id, start_ts, end_ts, message_count, summary FROM segment').get() as + | { id: number; start_ts: number; end_ts: number; message_count: number; summary: string | null } + | undefined + assert.deepEqual(segment, { + id: 7, + start_ts: 1000, + end_ts: 1010, + message_count: 1, + summary: 'legacy summary', + }) + + const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }> + assert.equal( + contextColumns.some((col) => col.name === 'segment_id'), + true + ) + assert.equal( + contextColumns.some((col) => col.name === 'session_id'), + false + ) + + const context = db.prepare('SELECT message_id, segment_id, topic_id FROM message_context').get() as { + message_id: number + segment_id: number + topic_id: number + } + assert.deepEqual(context, { message_id: 1, segment_id: 7, topic_id: 3 }) + + manager.closeAll() +}) + test('open preserves readonly access for current-schema databases', { skip: process.platform === 'win32' }, () => { 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 51d4ccbd..3359a9bb 100644 --- a/packages/node-runtime/src/migrations/chat-db-migrations.ts +++ b/packages/node-runtime/src/migrations/chat-db-migrations.ts @@ -71,10 +71,10 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { }, { version: 3, - description: 'Add segment and message_context tables', + description: 'Add chat_session and message_context tables', up: (db: DatabaseAdapter) => { db.exec(` - CREATE TABLE IF NOT EXISTS segment ( + CREATE TABLE IF NOT EXISTS chat_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, @@ -85,7 +85,7 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { `) try { - db.exec('CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts)') + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') } catch { // Index may already exist } @@ -93,13 +93,13 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { db.exec(` CREATE TABLE IF NOT EXISTS message_context ( message_id INTEGER PRIMARY KEY, - segment_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, topic_id INTEGER ) `) try { - db.exec('CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') } catch { // Index may already exist } @@ -202,7 +202,7 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { addColumnIfMissing(db, 'message', 'platform_message_id', 'TEXT DEFAULT NULL') db.exec(` - CREATE TABLE IF NOT EXISTS segment ( + CREATE TABLE IF NOT EXISTS chat_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, @@ -214,15 +214,15 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { db.exec(` CREATE TABLE IF NOT EXISTS message_context ( message_id INTEGER PRIMARY KEY, - segment_id INTEGER NOT NULL, + session_id INTEGER NOT NULL, topic_id INTEGER ) `) db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)') db.exec('CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id)') - db.exec('CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts)') - db.exec('CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') }, }, { @@ -241,8 +241,18 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { DROP INDEX IF EXISTS idx_context_session; `) - if (hasTable('chat_session') && !hasTable('segment')) { + const hasLegacyChatSession = hasTable('chat_session') + const hasSegment = hasTable('segment') + + if (hasLegacyChatSession && !hasSegment) { db.exec('ALTER TABLE chat_session RENAME TO segment') + } else if (hasLegacyChatSession && hasSegment) { + db.exec(` + INSERT OR IGNORE INTO segment (id, start_ts, end_ts, message_count, is_manual, summary) + SELECT id, start_ts, end_ts, message_count, is_manual, summary + FROM chat_session; + DROP TABLE chat_session; + `) } if (hasTable('message_context') && hasColumn(db, 'message_context', 'session_id')) {