diff --git a/packages/http-routes/src/errors.ts b/packages/http-routes/src/errors.ts index 6201054f..72956853 100644 --- a/packages/http-routes/src/errors.ts +++ b/packages/http-routes/src/errors.ts @@ -41,6 +41,9 @@ const HTTP_STATUS: Record = { [ApiErrorCode.SERVER_ERROR]: 500, } +// REST response envelope version; independent from the ChatLab file format version. +const API_RESPONSE_VERSION = '0.0.2' + export class ApiError extends Error { code: ApiErrorCode statusCode: number @@ -138,7 +141,7 @@ export function successResponse(data: T, meta?: Record) { data, meta: { timestamp: Math.floor(Date.now() / 1000), - version: '0.0.2', + version: API_RESPONSE_VERSION, ...meta, }, } diff --git a/packages/http-routes/src/routes/rest/session-provider.test.ts b/packages/http-routes/src/routes/rest/session-provider.test.ts index c018a075..ec9bab45 100644 --- a/packages/http-routes/src/routes/rest/session-provider.test.ts +++ b/packages/http-routes/src/routes/rest/session-provider.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import Fastify from 'fastify' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' import type { RestSessionProvider } from './session-provider' import { registerRestSessionRoutes } from './sessions' import { registerSystemRoutes } from './system' @@ -71,8 +72,9 @@ test('REST routes share one contract with an async Worker-backed provider', asyn registerRestSessionRoutes(app, provider) await app.ready() - const [status, sessions, detail, messages, members, overview, sql, exported] = await Promise.all([ + const [status, schema, sessions, detail, messages, members, overview, sql, exported] = await Promise.all([ app.inject({ method: 'GET', url: '/api/v1/status' }), + app.inject({ method: 'GET', url: '/api/v1/schema' }), app.inject({ method: 'GET', url: '/api/v1/sessions' }), app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}` }), app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}/messages?limit=1` }), @@ -83,6 +85,7 @@ test('REST routes share one contract with an async Worker-backed provider', asyn ]) assert.equal(status.json().data.sessionCount, 1) + assert.equal(schema.json().data.version, CHATLAB_FORMAT_VERSION) assert.deepEqual(sessions.json().data, [session]) assert.deepEqual(detail.json().data, session) assert.equal(messages.json().data.totalPages, 2) @@ -91,6 +94,7 @@ test('REST routes share one contract with an async Worker-backed provider', asyn assert.equal(overview.json().data.messageCount, 2) assert.deepEqual(sql.json().data.rows, [[2]]) assert.deepEqual(exported.json().data.members[0].aliases, ['A']) + assert.equal(exported.json().data.chatlab.version, CHATLAB_FORMAT_VERSION) assert.equal(exported.json().data.messages[0].sender, 'alice') const missingSql = await app.inject({ diff --git a/packages/http-routes/src/routes/rest/sessions.ts b/packages/http-routes/src/routes/rest/sessions.ts index 2565d0c0..65727763 100644 --- a/packages/http-routes/src/routes/rest/sessions.ts +++ b/packages/http-routes/src/routes/rest/sessions.ts @@ -6,6 +6,7 @@ */ import type { FastifyInstance } from 'fastify' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' import { successResponse, errorResponse, @@ -122,7 +123,7 @@ export function registerRestSessionRoutes(server: FastifyInstance, provider: Res const chatLabFormat = { chatlab: { - version: '0.0.2', + version: CHATLAB_FORMAT_VERSION, exportedAt: Math.floor(Date.now() / 1000), generator: 'ChatLab API', }, diff --git a/packages/http-routes/src/routes/rest/system.ts b/packages/http-routes/src/routes/rest/system.ts index 229ae205..26978fbe 100644 --- a/packages/http-routes/src/routes/rest/system.ts +++ b/packages/http-routes/src/routes/rest/system.ts @@ -6,6 +6,7 @@ */ import type { FastifyInstance } from 'fastify' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' import { successResponse } from '../../errors' export interface SystemRouteContext { @@ -33,7 +34,7 @@ export function registerSystemRoutes(server: FastifyInstance, ctx: SystemRouteCo server.get('/api/v1/schema', async () => { return successResponse({ format: 'ChatLab Format', - version: '0.0.2', + version: CHATLAB_FORMAT_VERSION, spec: { chatlab: { type: 'object', diff --git a/packages/node-runtime/src/export/format-exporter.ts b/packages/node-runtime/src/export/format-exporter.ts index 81a5d40d..9d648606 100644 --- a/packages/node-runtime/src/export/format-exporter.ts +++ b/packages/node-runtime/src/export/format-exporter.ts @@ -4,6 +4,7 @@ */ import type { DatabaseAdapter } from '@openchatlab/core' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' import { appLogger } from '../logging/app-logger' export type ExportFormat = 'txt' | 'json' | 'markdown' @@ -141,7 +142,7 @@ function exportAsJson( const data = { chatlab: { - version: '0.0.2', + version: CHATLAB_FORMAT_VERSION, exportedAt: Math.floor(Date.now() / 1000), generator: 'ChatLab', }, diff --git a/packages/node-runtime/src/merger/__tests__/merger.test.ts b/packages/node-runtime/src/merger/__tests__/merger.test.ts index 7b695468..13d075f4 100644 --- a/packages/node-runtime/src/merger/__tests__/merger.test.ts +++ b/packages/node-runtime/src/merger/__tests__/merger.test.ts @@ -1,8 +1,12 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' +import Database from 'better-sqlite3' import { checkConflictsFromSources, buildMergedOutput, serializeChatLabToJsonl } from '../index' +import { exportSessionToJson } from '../temp-db' import type { MergerDataSource, MergerSourceMeta } from '../index' import type { MergerMember, MergerMessage } from '@openchatlab/core' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' +import { BetterSqliteAdapter } from '../../better-sqlite3-adapter' function createMockSource( meta: MergerSourceMeta, @@ -91,6 +95,7 @@ describe('buildMergedOutput', () => { ) assert.ok(result.success) + assert.equal(result.chatLabData.chatlab.version, CHATLAB_FORMAT_VERSION) assert.equal(result.chatLabData.messages.length, 3) assert.equal(result.chatLabData.messages[0].timestamp, 100) assert.equal(result.chatLabData.messages[1].timestamp, 200) @@ -145,7 +150,7 @@ describe('serializeChatLabToJsonl', () => { const header = JSON.parse(lines[0]) assert.equal(header._type, 'header') - assert.ok(header.chatlab) + assert.equal(header.chatlab.version, CHATLAB_FORMAT_VERSION) assert.ok(header.meta) const member = JSON.parse(lines[1]) @@ -157,3 +162,44 @@ describe('serializeChatLabToJsonl', () => { assert.equal(msg.content, 'hi') }) }) + +describe('exportSessionToJson', () => { + it('uses the current ChatLab format version', () => { + const rawDb = new Database(':memory:') + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + group_id TEXT, + group_avatar TEXT + ); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT NOT NULL, + account_name TEXT, + group_nickname TEXT, + avatar TEXT + ); + CREATE TABLE message ( + sender_id INTEGER NOT NULL, + sender_account_name TEXT, + sender_group_nickname TEXT, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO meta (name, platform, type) VALUES ('Test', 'qq', 'group'); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'alice', 'Alice'); + INSERT INTO message (sender_id, sender_account_name, ts, type, content) + VALUES (1, 'Alice', 100, 0, 'Hello'); + `) + + try { + const exported = exportSessionToJson(new BetterSqliteAdapter(rawDb)) + assert.equal(exported.chatlab.version, CHATLAB_FORMAT_VERSION) + } finally { + rawDb.close() + } + }) +}) diff --git a/packages/node-runtime/src/merger/index.ts b/packages/node-runtime/src/merger/index.ts index dcfc1a64..d27ede2c 100644 --- a/packages/node-runtime/src/merger/index.ts +++ b/packages/node-runtime/src/merger/index.ts @@ -17,6 +17,7 @@ import { type MergedMember, type MergedMessage, } from '@openchatlab/core' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' // ==================== Data source abstraction ==================== @@ -49,7 +50,7 @@ export interface MergeSourceInfo { } export interface ChatLabHeader { - version: string + version: typeof CHATLAB_FORMAT_VERSION exportedAt: number generator: string description: string @@ -172,7 +173,7 @@ export function buildMergedOutput( const chatLabData: ChatLabOutput = { chatlab: { - version: '0.0.1', + version: CHATLAB_FORMAT_VERSION, exportedAt: Math.floor(Date.now() / 1000), generator: 'ChatLab Merge Tool', description: `Merged from ${dataSources.length} files`, diff --git a/packages/node-runtime/src/merger/temp-db.ts b/packages/node-runtime/src/merger/temp-db.ts index 1c5f4f02..4ead4920 100644 --- a/packages/node-runtime/src/merger/temp-db.ts +++ b/packages/node-runtime/src/merger/temp-db.ts @@ -11,7 +11,7 @@ import * as fs from 'fs' import * as path from 'path' import type { DatabaseAdapter } from '@openchatlab/core' -import type { ParsedMember, ParsedMessage } from '@openchatlab/shared-types' +import { CHATLAB_FORMAT_VERSION, type ParsedMember, type ParsedMessage } from '@openchatlab/shared-types' import type { MergerDataSource, MergerSourceMeta } from './index' import type { MergerMember, MergerMessage } from '@openchatlab/core' @@ -291,7 +291,7 @@ function createDataSourceFromReader(reader: TempDbReader): MergerDataSource { // ==================== Session export ==================== export interface ExportedSession { - chatlab: { version: string; exportedAt: number; generator: string; description: string } + chatlab: { version: typeof CHATLAB_FORMAT_VERSION; exportedAt: number; generator: string; description: string } meta: { name: string; platform: string; type: string; groupId?: string; groupAvatar?: string } members: Array<{ platformId: string; accountName: string; groupNickname?: string; avatar?: string }> messages: Array<{ @@ -339,7 +339,7 @@ export function exportSessionToJson(db: DatabaseAdapter): ExportedSession { return { chatlab: { - version: '0.0.1', + version: CHATLAB_FORMAT_VERSION, exportedAt: Math.floor(Date.now() / 1000), generator: 'ChatLab Export', description: `Exported from session: ${meta.name}`, diff --git a/packages/shared-types/chatlab-format.ts b/packages/shared-types/chatlab-format.ts new file mode 100644 index 00000000..04459f88 --- /dev/null +++ b/packages/shared-types/chatlab-format.ts @@ -0,0 +1,11 @@ +/** ChatLab 当前生成文件时使用的格式版本。 */ +export const CHATLAB_FORMAT_VERSION = '0.0.2' as const + +/** ChatLab 当前可以读取和验证的历史格式版本。 */ +export const CHATLAB_SUPPORTED_FORMAT_VERSIONS = ['0.0.1', CHATLAB_FORMAT_VERSION] as const + +export type ChatLabFormatVersion = (typeof CHATLAB_SUPPORTED_FORMAT_VERSIONS)[number] + +export function isSupportedChatLabFormatVersion(version: string): version is ChatLabFormatVersion { + return (CHATLAB_SUPPORTED_FORMAT_VERSIONS as readonly string[]).includes(version) +} diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 9d4212c2..4e883482 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -78,6 +78,13 @@ export enum ChatType { PRIVATE = 'private', } +export { + CHATLAB_FORMAT_VERSION, + CHATLAB_SUPPORTED_FORMAT_VERSIONS, + isSupportedChatLabFormatVersion, + type ChatLabFormatVersion, +} from './chatlab-format' + // ==================== AI Assistants ==================== export const GENERAL_ASSISTANT_IDS = ['general_cn', 'general_tw', 'general_en', 'general_ja'] as const diff --git a/src/types/format.ts b/src/types/format.ts index d4526a4a..89d4a4d8 100644 --- a/src/types/format.ts +++ b/src/types/format.ts @@ -11,7 +11,7 @@ import type { ChatPlatform, ChatType, MessageType, MemberRole } from './base' * ChatLab 格式版本信息 */ export interface ChatLabHeader { - version: string // 格式版本,如 "0.0.1" + version: string // 格式版本,如 "0.0.2" exportedAt: number // 导出时间戳(秒) generator?: string // 生成工具名称(可选) description?: string // 描述信息(可选,自定义内容) diff --git a/tests/chatlab-json-export-roundtrip.test.ts b/tests/chatlab-json-export-roundtrip.test.ts index e85ef2d0..6508f1ca 100644 --- a/tests/chatlab-json-export-roundtrip.test.ts +++ b/tests/chatlab-json-export-roundtrip.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import test from 'node:test' import Database from 'better-sqlite3' import { detectFormat, parseFileSync } from '@openchatlab/parser' +import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types' import { CHAT_DB_SCHEMA } from '../packages/core/src/schema/tables' import { BetterSqliteAdapter } from '../packages/node-runtime/src/better-sqlite3-adapter' import { exportWithFormat } from '../packages/node-runtime/src/export/format-exporter' @@ -60,7 +61,7 @@ test('exports JSON as ChatLab format that can be parsed for re-import', async () assert.equal(result.success, true) const exported = JSON.parse(result.content) - assert.equal(exported.chatlab.version, '0.0.2') + assert.equal(exported.chatlab.version, CHATLAB_FORMAT_VERSION) assert.equal(exported.meta.ownerId, 'alice') assert.equal(exported.meta.sourceSessionId, 'session-1') assert.deepEqual(exported.members[0].aliases, ['Alice'])