refactor(format): centralize ChatLab format version

This commit is contained in:
digua committed 2026-07-19 15:16:29 +08:00
1 parent 9c8210e18c
commit e5ec60aaaf
12 files changed
+89 -13

No files matched your search

+4 -1
View File
@@ -41,6 +41,9 @@ const HTTP_STATUS: Record<ApiErrorCode, number> = {
[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<T>(data: T, meta?: Record<string, unknown>) {
data,
meta: {
timestamp: Math.floor(Date.now() / 1000),
version: '0.0.2',
version: API_RESPONSE_VERSION,
...meta,
},
}
@@ -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({
@@ -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',
},
@@ -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',
@@ -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',
},
@@ -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()
}
})
})
+3 -2
View File
@@ -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`,
+3 -3
View File
@@ -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}`,
+11
View File
@@ -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)
}
+7
View File
@@ -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