mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix: normalize legacy tool names and harden cli chat command
- Add normalizeBuiltinToolNames() to map old session-based tool names (search_sessions, get_session_messages, get_session_summaries) to current segment names; applied in assistant parser, agent-stream-runner, and desktop tool-filter so existing assistant configs keep working - Propagate agent stream error chunks as thrown Errors in CLI chat command - Pass assistantId through ResolvedAIChatTarget to agent stream - Preserve legacy chat_session rows in v6 migration when segment table already exists (INSERT OR IGNORE + DROP); revert v3/v5 DDL to chat_session - Rename session_id alias to segment_id in relationship query - Add tests: assistant-parser normalization, tool-filter legacy names, agent-stream-runner legacy allowlist, REPL error resilience, migration paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b17b09354
commit
62ad3c6248
14 files changed
+422
-43
No files matched your search
@@ -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, [])
|
||||
|
||||
|
||||
@@ -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<string> | 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(
|
||||
|
||||
@@ -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<string, { id: string; sessionId: string; title: string | null; assistantId: string }>(
|
||||
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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 模型系统类型
|
||||
|
||||
@@ -12,6 +12,20 @@ export interface BuiltinToolCatalogEntry {
|
||||
category: ToolCategory
|
||||
}
|
||||
|
||||
const LEGACY_BUILTIN_TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number, string>,
|
||||
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<number, { sum: number; count: number }>()
|
||||
@@ -234,8 +234,8 @@ function queryMessageLevelStats(
|
||||
const monthlyRespMap = new Map<string, Map<number, { sum: number; count: number }>>()
|
||||
const monthlyDtMap = new Map<string, Map<number, number>>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in new issue
Block a user