fix(export): generate importable ChatLab JSON

This commit is contained in:
digua committed 2026-07-12 21:27:39 +08:00
1 parent e1288c3cc7
commit 96235915b8
3 files changed
+235 -14

No files matched your search

@@ -4,6 +4,7 @@
*/
import type { DatabaseAdapter } from '@openchatlab/core'
import { appLogger } from '../logging/app-logger'
export type ExportFormat = 'txt' | 'json' | 'markdown'
@@ -26,7 +27,31 @@ export interface FormatExportResult {
interface MessageRow {
ts: number
senderName: string
sender: string
accountName: string
groupNickname: string | null
type: number
content: string | null
platformMessageId: string | null
replyToMessageId: string | null
}
interface MetaRow {
name: string
platform: string
type: string
groupId: string | null
groupAvatar: string | null
ownerId: string | null
}
interface MemberRow {
platformId: string
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
roles: string | null
}
function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endTs: number }): MessageRow[] {
@@ -34,7 +59,13 @@ function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endT
const sql = `
SELECT msg.ts,
COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName,
msg.content
m.platform_id as sender,
COALESCE(msg.sender_account_name, m.account_name, m.platform_id) as accountName,
COALESCE(msg.sender_group_nickname, m.group_nickname) as groupNickname,
msg.type,
msg.content,
msg.platform_message_id as platformMessageId,
msg.reply_to_message_id as replyToMessageId
FROM message msg
JOIN member m ON msg.sender_id = m.id
${hasFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''}
@@ -47,10 +78,6 @@ function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endT
return db.prepare(sql).all(...params) as unknown as MessageRow[]
}
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString()
}
function formatTimeShort(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
@@ -69,16 +96,75 @@ function exportAsTxt(messages: MessageRow[], sessionName: string): string {
return lines.join('\n')
}
function exportAsJson(messages: MessageRow[], sessionName: string): string {
function parseOptionalArray<T>(value: string | null): T[] | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) && parsed.length > 0 ? (parsed as T[]) : undefined
} catch {
return undefined
}
}
function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName: string): string {
const meta = db
.prepare(
`SELECT name,
platform,
type,
group_id as groupId,
group_avatar as groupAvatar,
owner_id as ownerId
FROM meta
LIMIT 1`
)
.get() as unknown as MetaRow | undefined
if (!meta) throw new Error('Cannot read session metadata')
const members = db
.prepare(
`SELECT platform_id as platformId,
account_name as accountName,
group_nickname as groupNickname,
aliases,
avatar,
roles
FROM member
ORDER BY id ASC`
)
.all() as unknown as MemberRow[]
const data = {
sessionName,
exportTime: new Date().toISOString(),
totalMessages: messages.length,
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'ChatLab',
},
meta: {
name: meta.name || sessionName,
platform: meta.platform,
type: meta.type,
groupId: meta.groupId || undefined,
groupAvatar: meta.groupAvatar || undefined,
ownerId: meta.ownerId || undefined,
},
members: members.map((member) => ({
platformId: member.platformId,
accountName: member.accountName || member.platformId,
groupNickname: member.groupNickname || undefined,
aliases: parseOptionalArray<string>(member.aliases),
avatar: member.avatar || undefined,
roles: parseOptionalArray<{ id: string; name?: string }>(member.roles),
})),
messages: messages.map((msg) => ({
sender: msg.sender,
accountName: msg.accountName,
groupNickname: msg.groupNickname || undefined,
timestamp: msg.ts,
time: formatTime(msg.ts),
sender: msg.senderName,
content: msg.content || '',
type: msg.type,
content: msg.content,
platformMessageId: msg.platformMessageId || undefined,
replyToMessageId: msg.replyToMessageId || undefined,
})),
}
return JSON.stringify(data, null, 2)
@@ -133,7 +219,7 @@ export function exportWithFormat(
content = exportAsTxt(messages, params.sessionName)
break
case 'json':
content = exportAsJson(messages, params.sessionName)
content = exportAsJson(db, messages, params.sessionName)
break
case 'markdown':
content = exportAsMarkdown(messages, params.sessionName)
@@ -142,8 +228,15 @@ export function exportWithFormat(
const timestamp = Date.now()
const filename = `${params.sessionName}_export_${timestamp}.${ext}`
appLogger.info('export', 'chat export generated', {
sessionId: params.sessionId,
format: params.format,
totalMessages: messages.length,
filteredByTime: !!params.timeFilter,
})
return { success: true, totalMessages: messages.length, content, filename, mimeType: mime }
} catch (error) {
appLogger.error('export', 'chat export failed', error)
return {
success: false,
error: String(error),
@@ -11,7 +11,7 @@ const exportFormat = defineModel<ExportFormat>('exportFormat', { default: 'txt'
const formatTabItems = computed(() => [
{ label: 'TXT', value: 'txt' },
{ label: 'JSON', value: 'json' },
{ label: 'ChatLab JSON', value: 'json' },
{ label: 'Markdown', value: 'markdown' },
])
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import Database from 'better-sqlite3'
import { detectFormat, parseFileSync } from '@openchatlab/parser'
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'
test('exports JSON as ChatLab format that can be parsed for re-import', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'chatlab-json-export-'))
const filePath = join(tempDir, 'roundtrip.json')
const rawDb = new Database(':memory:')
try {
rawDb.exec(CHAT_DB_SCHEMA)
rawDb
.prepare(
`INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run('测试群', 'qq', 'group', 100, 'group-1', 'group-avatar', 'alice')
const insertMember = rawDb.prepare(
`INSERT INTO member (platform_id, account_name, group_nickname, aliases, avatar, roles)
VALUES (?, ?, ?, ?, ?, ?)`
)
const aliceId = insertMember.run(
'alice',
'Alice Account',
'爱丽丝',
JSON.stringify(['Alice']),
'alice-avatar',
JSON.stringify([{ id: 'owner' }])
).lastInsertRowid
const bobId = insertMember.run('bob', 'Bob Account', '鲍勃', '[]', null, '[]').lastInsertRowid
const insertMessage = rawDb.prepare(
`INSERT INTO message
(sender_id, sender_account_name, sender_group_nickname, ts, type, content, platform_message_id, reply_to_message_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
insertMessage.run(aliceId, 'Alice Account', '爱丽丝', 100, 0, '筛选范围外', 'msg-1', null)
insertMessage.run(bobId, 'Bob Account', '鲍勃', 200, 0, '你好', 'msg-2', null)
insertMessage.run(aliceId, 'Alice Account', '爱丽丝', 250, 1, null, 'msg-3', 'msg-2')
const result = exportWithFormat(
{
sessionId: 'session-1',
sessionName: 'UI 中的会话名',
format: 'json',
timeFilter: { startTs: 150, endTs: 300 },
},
() => new BetterSqliteAdapter(rawDb)
)
assert.equal(result.success, true)
const exported = JSON.parse(result.content)
assert.equal(exported.chatlab.version, '0.0.2')
assert.equal(exported.meta.ownerId, 'alice')
assert.deepEqual(exported.members[0].aliases, ['Alice'])
assert.deepEqual(
exported.messages.map((message: { timestamp: number }) => message.timestamp),
[200, 250]
)
writeFileSync(filePath, result.content, 'utf8')
assert.equal(detectFormat(filePath)?.id, 'chatlab')
const parsed = await parseFileSync(filePath)
assert.deepEqual(parsed.meta, {
name: '测试群',
platform: 'qq',
type: 'group',
groupId: 'group-1',
groupAvatar: 'group-avatar',
})
assert.deepEqual(
parsed.members.map((member) => ({
platformId: member.platformId,
accountName: member.accountName,
groupNickname: member.groupNickname,
roles: member.roles,
})),
[
{
platformId: 'alice',
accountName: 'Alice Account',
groupNickname: '爱丽丝',
roles: [{ id: 'owner' }],
},
{
platformId: 'bob',
accountName: 'Bob Account',
groupNickname: '鲍勃',
roles: undefined,
},
]
)
assert.deepEqual(parsed.messages, [
{
senderPlatformId: 'bob',
senderAccountName: 'Bob Account',
senderGroupNickname: '鲍勃',
timestamp: 200,
type: 0,
content: '你好',
platformMessageId: 'msg-2',
replyToMessageId: undefined,
},
{
senderPlatformId: 'alice',
senderAccountName: 'Alice Account',
senderGroupNickname: '爱丽丝',
timestamp: 250,
type: 1,
content: null,
platformMessageId: 'msg-3',
replyToMessageId: 'msg-2',
},
])
} finally {
rawDb.close()
rmSync(tempDir, { recursive: true, force: true })
}
})