feat(demo): add rolling cross-platform imports

This commit is contained in:
digua committed 2026-07-26 01:43:43 +08:00
1 parent e9004ca71f
commit 9e7c76fb89
19 files changed
+859 -174

No files matched your search

+10 -51
View File
@@ -8,6 +8,7 @@ import {
ArchiveImportSourceManager,
createChatLabTempDir,
getChatLabTempScopeDir,
importDemoSessions,
type DatabaseManager,
} from '@openchatlab/node-runtime'
import {
@@ -25,7 +26,6 @@ import {
import type { AutoImportResult, StreamImportOptions } from '../../../import'
import { resolveNativeBinding } from './helpers'
const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo'
const ARCHIVE_UPLOAD_LIMIT = 50 * 1024 * 1024 * 1024
interface ImportRouteOptions {
@@ -407,17 +407,9 @@ export function registerImportRoutes(
// ==================== Demo Import ====================
const DEMO_FILES = [
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
]
server.post<{ Body: { locale?: string } }>('/_web/demo/import', async (request, reply) => {
const locale = (request.body as any)?.locale || 'en'
const locale = (request.body as any)?.locale === 'cn' ? 'cn' : 'en'
const nativeBinding = resolveNativeBinding()
const total = DEMO_FILES.length
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
@@ -429,46 +421,13 @@ export function registerImportRoutes(
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`)
}
const tmpDir = createChatLabTempDir('imports', 'cli-demo-')
try {
const localPaths: string[] = []
for (let i = 0; i < total; i++) {
sendEvent('progress', { stage: 'downloading', current: i + 1, total })
const localPath = path.join(tmpDir, DEMO_FILES[i])
const resp = await fetch(`${DEMO_BASE_URL}/${locale}/${DEMO_FILES[i]}`, {
signal: AbortSignal.timeout(60_000),
})
if (!resp.ok) throw new Error(`Download demo failed (${DEMO_FILES[i]}): ${resp.status}`)
fs.writeFileSync(localPath, Buffer.from(await resp.arrayBuffer()))
localPaths.push(localPath)
}
sendEvent('progress', { stage: 'importing', current: 1, total })
const groupResult = await streamImport(dbManager, localPaths[0], { nativeBinding })
if (!groupResult.success) throw new Error(groupResult.error || 'Failed to import group demo')
const privateSessionIds: string[] = []
for (let i = 1; i < localPaths.length; i++) {
sendEvent('progress', { stage: 'importing', current: i + 1, total })
const result = await streamImport(dbManager, localPaths[i], { nativeBinding })
if (!result.success) throw new Error(result.error || `Failed to import private demo: ${DEMO_FILES[i]}`)
if (result.sessionId) privateSessionIds.push(result.sessionId)
}
sendEvent('progress', { stage: 'done', current: total, total })
sendEvent('result', {
success: true,
groupSessionId: groupResult.sessionId,
privateSessionIds,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
sendEvent('progress', { stage: 'error', current: 0, total, message })
sendEvent('result', { success: false, error: message })
} finally {
reply.raw.end()
cleanupTemp(tmpDir)
}
const result = await importDemoSessions({
locale,
tempPrefix: 'cli-demo-',
importFile: (filePath) => streamImport(dbManager, filePath, { nativeBinding }),
onProgress: (progress) => sendEvent('progress', progress),
})
sendEvent('result', result)
reply.raw.end()
})
}
+8 -92
View File
@@ -3,50 +3,10 @@
*/
import { ipcMain } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import { createChatLabTempDir } from '@openchatlab/node-runtime/temp-workspace'
import { importDemoSessions, type DemoImportProgress } from '@openchatlab/node-runtime'
import * as worker from '../worker/workerManager'
import type { IpcContext } from './types'
const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo'
interface DemoProgress {
stage: 'downloading' | 'importing' | 'done' | 'error'
/** 当前处理的文件序号 (1-based) */
current: number
total: number
message?: string
}
function getDemoTempDir(): string {
return createChatLabTempDir('imports', 'desktop-demo-')
}
async function downloadFile(url: string, destPath: string): Promise<void> {
const tmpPath = destPath + '.tmp'
const response = await fetch(url, { signal: AbortSignal.timeout(60_000) })
if (!response.ok) {
throw new Error(`Download failed: HTTP ${response.status}`)
}
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length < 100) {
throw new Error(`Downloaded file too small (${buffer.length} bytes)`)
}
fs.writeFileSync(tmpPath, buffer)
fs.renameSync(tmpPath, destPath)
}
function cleanupDemoTemp(tempDir: string): void {
try {
fs.rmSync(tempDir, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
}
export function registerDemoHandlers(ctx: IpcContext): void {
const { win } = ctx
@@ -54,13 +14,6 @@ export function registerDemoHandlers(ctx: IpcContext): void {
* 下载并导入 Demo 示例数据
* 返回群聊和私聊的 sessionId
*/
const DEMO_FILES = [
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
]
ipcMain.handle(
'demo:downloadAndImport',
async (
@@ -72,53 +25,16 @@ export function registerDemoHandlers(ctx: IpcContext): void {
privateSessionIds?: string[]
error?: string
}> => {
const tempDir = getDemoTempDir()
const total = DEMO_FILES.length
const sendProgress = (progress: DemoProgress) => {
const sendProgress = (progress: DemoImportProgress) => {
win.webContents.send('demo:progress', progress)
}
try {
const localPaths: string[] = []
for (let i = 0; i < total; i++) {
sendProgress({ stage: 'downloading', current: i + 1, total })
const localPath = path.join(tempDir, DEMO_FILES[i])
await downloadFile(`${DEMO_BASE_URL}/${locale}/${DEMO_FILES[i]}`, localPath)
localPaths.push(localPath)
}
sendProgress({ stage: 'importing', current: 1, total })
const groupResult = await worker.streamImport(localPaths[0])
if (!groupResult.success || !groupResult.sessionId) {
throw new Error(groupResult.error || 'Failed to import group demo')
}
const privateSessionIds: string[] = []
for (let i = 1; i < localPaths.length; i++) {
sendProgress({ stage: 'importing', current: i + 1, total })
const result = await worker.streamImport(localPaths[i])
if (!result.success || !result.sessionId) {
throw new Error(result.error || `Failed to import private demo: ${DEMO_FILES[i]}`)
}
privateSessionIds.push(result.sessionId)
}
sendProgress({ stage: 'done', current: total, total })
return {
success: true,
groupSessionId: groupResult.sessionId,
privateSessionIds,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('[Demo] Download and import failed:', message)
sendProgress({ stage: 'error', current: 0, total, message })
return { success: false, error: message }
} finally {
cleanupDemoTemp(tempDir)
}
return importDemoSessions({
locale: locale === 'cn' ? 'cn' : 'en',
tempPrefix: 'desktop-demo-',
importFile: (filePath) => worker.streamImport(filePath),
onProgress: sendProgress,
})
}
)
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import logoSvg from '@/assets/images/logo.svg'
import AgreementModal from '@/components/home/AgreementModal.vue'
import DemoImportButton from '@/components/home/DemoImportButton.vue'
import HomeFooter from '@/components/home/HomeFooter.vue'
import ImportArea from '@/components/import/ImportArea.vue'
import { availableLocales, type LocaleType } from '@/i18n'
@@ -150,9 +151,10 @@ onMounted(() => {
</div>
<div
class="landing-reveal mt-4 flex justify-center transition-all delay-200 duration-500 ease-out"
class="landing-reveal mt-4 flex flex-wrap items-center justify-center gap-2 transition-all delay-200 duration-500 ease-out"
:class="isMounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'"
>
<DemoImportButton v-if="sessions.length === 0" :generate-session-indexes="false" />
<UButton
:href="tutorialExportUrl"
target="_blank"
+37
View File
@@ -0,0 +1,37 @@
const DEMO_ORIGIN = 'https://chatlab.fun/assets/demo'
const DEMO_LOCALES = new Set(['cn', 'en'])
const DEMO_FILES = new Set([
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
])
export async function proxyDemoRequest(context, fetchRemote = fetch) {
const rawPath = Array.isArray(context.params.path) ? context.params.path.join('/') : String(context.params.path || '')
const [locale, filename, ...extra] = rawPath.split('/')
if (extra.length > 0 || !DEMO_LOCALES.has(locale) || !DEMO_FILES.has(filename)) {
return new Response('Not found', { status: 404 })
}
try {
const upstream = await fetchRemote(`${DEMO_ORIGIN}/${locale}/${filename}`)
if (!upstream.ok || !upstream.body) {
return new Response('Demo asset unavailable', { status: upstream.status || 502 })
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') || 'application/json',
'Cache-Control': 'public, max-age=86400',
},
})
} catch {
return new Response('Demo asset unavailable', { status: 502 })
}
}
export function onRequestGet(context) {
return proxyDemoRequest(context)
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { proxyDemoRequest } from './[[path]].js'
describe('Web WASM demo asset proxy', () => {
it('only proxies known localized demo files', async () => {
const urls = []
const response = await proxyDemoRequest({ params: { path: 'cn/demo-group.json' } }, async (url) => {
urls.push(url)
return new Response('demo content', {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
assert.equal(response.status, 200)
assert.equal(response.headers.get('content-type'), 'application/json')
assert.equal(await response.text(), 'demo content')
assert.deepEqual(urls, ['https://chatlab.fun/assets/demo/cn/demo-group.json'])
})
it('rejects unknown paths before making a remote request', async () => {
let requested = false
const response = await proxyDemoRequest({ params: { path: 'cn/../../secret.json' } }, async () => {
requested = true
return new Response()
})
assert.equal(response.status, 404)
assert.equal(requested, false)
})
})
+7
View File
@@ -423,6 +423,13 @@ export * as sessionIndexService from './services/session-index-service'
export * as summaryService from './services/summary-service'
export * as exportService from './services/export-service'
export * as ownerProfileService from './services/owner-profile-service'
export {
importDemoSessions,
type DemoImportFileResult,
type DemoImportProgress,
type DemoImportResult,
type ImportDemoSessionsOptions,
} from './services/demo-import'
export * as contactsService from './services/contacts'
export * as peopleRelationshipsService from './services/people/relationships'
export * as globalInsightService from './services/global-insight'
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import * as fs from 'node:fs'
import { describe, it } from 'node:test'
import type { DemoImportProgress } from './demo-import'
import { importDemoSessions } from './demo-import'
function sourceTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}+08:00`).getTime() / 1000)
}
function createDemoDocument(name: string, timestamps: number[]): string {
return JSON.stringify({
chatlab: {
version: '0.0.2',
exportedAt: sourceTimestamp('2000-01-01', '00:00:00'),
generator: 'ChatLab Demo',
description: 'x'.repeat(128),
demoTimeline: {
version: 1,
mode: 'relative',
referenceYear: 2000,
timeZoneOffsetMinutes: 480,
},
},
meta: { name, platform: 'qq', type: 'private' },
members: [],
messages: timestamps.map((timestamp, index) => ({
sender: '1',
accountName: 'Demo',
timestamp,
type: 0,
platformMessageId: `${name}-${index}`,
content: 'demo',
})),
})
}
describe('importDemoSessions', () => {
it('rebases all downloaded sessions before importing them sequentially', async () => {
const sourceLatest = sourceTimestamp('2000-12-10', '22:30:00')
const documents = [
createDemoDocument('group', [sourceTimestamp('2000-02-01', '09:00:00'), sourceLatest]),
createDemoDocument('private-a', [sourceTimestamp('2000-02-01', '08:30:00')]),
createDemoDocument('private-b', [sourceTimestamp('2000-06-01', '10:00:00')]),
createDemoDocument('private-c', [sourceTimestamp('2000-12-09', '21:00:00')]),
]
let downloadIndex = 0
const imported: Array<{ name: string; document: any }> = []
const progress: DemoImportProgress[] = []
const now = new Date('2026-07-25T04:00:00.000Z')
const result = await importDemoSessions({
locale: 'cn',
tempPrefix: 'demo-service-test-',
fetchImpl: async function (this: unknown) {
assert.equal(this, globalThis)
return new Response(documents[downloadIndex++], { status: 200 })
},
now: () => now,
onProgress: (event) => progress.push(event),
importFile: async (filePath) => {
const document = JSON.parse(fs.readFileSync(filePath, 'utf8'))
imported.push({ name: filePath, document })
return { success: true, sessionId: `session-${imported.length}` }
},
})
const expectedLatest = new Date(now)
expectedLatest.setDate(expectedLatest.getDate() - 1)
expectedLatest.setHours(22, 30, 0, 0)
const latestTimestamp = Math.floor(expectedLatest.getTime() / 1000)
const offset = latestTimestamp - sourceLatest
assert.deepEqual(result, {
success: true,
groupSessionId: 'session-1',
privateSessionIds: ['session-2', 'session-3', 'session-4'],
})
assert.equal(imported.length, 4)
assert.equal(imported[0].document.messages[1].timestamp, latestTimestamp)
assert.equal(imported[1].document.messages[0].timestamp, sourceTimestamp('2000-02-01', '08:30:00') + offset)
assert.ok(imported.every(({ document }) => document.chatlab.exportedAt === Math.floor(now.getTime() / 1000)))
assert.deepEqual(
progress.map(({ stage, current }) => `${stage}:${current}`),
[
'downloading:1',
'downloading:2',
'downloading:3',
'downloading:4',
'importing:1',
'importing:2',
'importing:3',
'importing:4',
'done:4',
]
)
assert.ok(imported.every(({ name }) => !fs.existsSync(name)))
})
it('does not import any session when a downloaded document is invalid', async () => {
let importCount = 0
let downloadIndex = 0
const progress: DemoImportProgress[] = []
const responses = [
createDemoDocument('group', [sourceTimestamp('2000-12-10', '22:30:00')]),
'x'.repeat(128),
createDemoDocument('private-b', [sourceTimestamp('2000-12-09', '21:00:00')]),
createDemoDocument('private-c', [sourceTimestamp('2000-12-08', '21:00:00')]),
]
const result = await importDemoSessions({
locale: 'en',
tempPrefix: 'demo-service-invalid-test-',
fetchImpl: async () => new Response(responses[downloadIndex++], { status: 200 }),
onProgress: (event) => progress.push(event),
importFile: async () => {
importCount += 1
return { success: true, sessionId: 'unexpected' }
},
})
assert.equal(result.success, false)
assert.match(result.error ?? '', /not valid JSON/)
assert.equal(importCount, 0)
assert.equal(progress.at(-1)?.stage, 'error')
})
})
@@ -0,0 +1,115 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { rebaseChatLabDemoDocuments } from '@openchatlab/parser'
import { appLogger } from '../logging/app-logger'
import { createChatLabTempDir, removeChatLabTempDir } from '../temp-workspace'
const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo'
const DEMO_FILES = [
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
] as const
export interface DemoImportProgress {
stage: 'downloading' | 'importing' | 'done' | 'error'
current: number
total: number
message?: string
}
export interface DemoImportFileResult {
success: boolean
sessionId?: string
error?: string
}
export interface DemoImportResult {
success: boolean
groupSessionId?: string
privateSessionIds?: string[]
error?: string
}
export interface ImportDemoSessionsOptions {
locale: 'cn' | 'en'
tempPrefix: string
importFile: (filePath: string) => Promise<DemoImportFileResult>
onProgress?: (progress: DemoImportProgress) => void
fetchImpl?: typeof fetch
now?: () => Date
}
/**
* Download, rebase and import the official Demo sessions for Node runtimes.
* Electron and CLI Web provide only their platform-specific import callback.
*/
export async function importDemoSessions(options: ImportDemoSessionsOptions): Promise<DemoImportResult> {
const total = DEMO_FILES.length
const fetchImpl = options.fetchImpl ?? fetch
let tempDir: string | undefined
try {
const createdTempDir = createChatLabTempDir('imports', options.tempPrefix)
tempDir = createdTempDir
appLogger.info('demo-import', 'Demo import started', { locale: options.locale, sessionCount: total })
const downloaded: string[] = []
for (let index = 0; index < total; index++) {
const filename = DEMO_FILES[index]
options.onProgress?.({ stage: 'downloading', current: index + 1, total })
const response = await fetchImpl.call(globalThis, `${DEMO_BASE_URL}/${options.locale}/${filename}`, {
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) throw new Error(`Download demo failed (${filename}): HTTP ${response.status}`)
const content = Buffer.from(await response.arrayBuffer())
if (content.byteLength < 100) {
throw new Error(`Downloaded demo file is too small (${filename})`)
}
downloaded.push(content.toString('utf8'))
}
const rebased = rebaseChatLabDemoDocuments(downloaded, options.now?.() ?? new Date())
const localPaths = rebased.documents.map((document, index) => {
const localPath = path.join(createdTempDir, DEMO_FILES[index])
fs.writeFileSync(localPath, document, 'utf8')
return localPath
})
appLogger.info('demo-import', 'Demo timeline prepared', {
sessionCount: total,
offsetSeconds: rebased.offsetSeconds,
latestTimestamp: rebased.latestTimestamp,
})
const sessionIds: string[] = []
for (let index = 0; index < localPaths.length; index++) {
options.onProgress?.({ stage: 'importing', current: index + 1, total })
const result = await options.importFile(localPaths[index])
if (!result.success || !result.sessionId) {
throw new Error(result.error || `Failed to import demo: ${DEMO_FILES[index]}`)
}
sessionIds.push(result.sessionId)
}
const [groupSessionId, ...privateSessionIds] = sessionIds
options.onProgress?.({ stage: 'done', current: total, total })
appLogger.info('demo-import', 'Demo import completed', { sessionCount: sessionIds.length })
return { success: true, groupSessionId, privateSessionIds }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
options.onProgress?.({ stage: 'error', current: 0, total, message })
appLogger.error('demo-import', 'Demo import failed', error)
return { success: false, error: message }
} finally {
if (tempDir) {
try {
removeChatLabTempDir(tempDir, 'imports')
} catch (error) {
appLogger.warn('demo-import', 'Failed to clean up Demo temporary directory', error)
}
}
}
}
+1
View File
@@ -24,3 +24,4 @@ export {
type WeFlowJsonParseOptions,
type WeFlowJsonParseResult,
} from './src/browser/weflow'
export { rebaseChatLabDemoDocuments, type RebasedChatLabDemoDocuments } from './src/chatlab-demo-timeline'
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { rebaseChatLabDemoDocuments } from './chatlab-demo-timeline'
const DEMO_TIME_ZONE_OFFSET_MINUTES = 8 * 60
function sourceTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}+08:00`).getTime() / 1000)
}
function createDocument(timestamps: number[], options: { includeTimeline?: boolean; offset?: number } = {}): string {
const chatlab: Record<string, unknown> = {
version: '0.0.2',
exportedAt: sourceTimestamp('2000-01-01', '00:00:00'),
generator: 'ChatLab Demo',
}
if (options.includeTimeline !== false) {
chatlab.demoTimeline = {
version: 1,
mode: 'relative',
referenceYear: 2000,
timeZoneOffsetMinutes: options.offset ?? DEMO_TIME_ZONE_OFFSET_MINUTES,
}
}
return JSON.stringify({
chatlab,
meta: { name: 'Demo', platform: 'qq', type: 'private' },
members: [],
messages: timestamps.map((timestamp, index) => ({
sender: '1',
accountName: 'Demo',
timestamp,
type: 0,
platformMessageId: `m${index + 1}`,
content: 'demo',
})),
})
}
describe('rebaseChatLabDemoDocuments', () => {
it('rebases all documents with one offset and preserves their relative timeline', () => {
const groupTimestamps = [sourceTimestamp('2000-02-01', '09:00:00'), sourceTimestamp('2000-12-10', '22:30:00')]
const privateTimestamps = [sourceTimestamp('2000-07-11', '10:15:00'), sourceTimestamp('2000-12-09', '21:00:00')]
const inputs = [createDocument(groupTimestamps), createDocument(privateTimestamps)]
const now = new Date('2026-07-25T04:00:00.000Z')
const result = rebaseChatLabDemoDocuments(inputs, now)
const rebased = result.documents.map((document) => JSON.parse(document))
const expectedLatest = new Date(now)
expectedLatest.setDate(expectedLatest.getDate() - 1)
expectedLatest.setHours(22, 30, 0, 0)
assert.equal(result.sourceLatestTimestamp, groupTimestamps[1])
assert.equal(result.latestTimestamp, Math.floor(expectedLatest.getTime() / 1000))
assert.equal(result.offsetSeconds, result.latestTimestamp - result.sourceLatestTimestamp)
assert.deepEqual(
rebased[0].messages.map((message: { timestamp: number }) => message.timestamp),
groupTimestamps.map((timestamp) => timestamp + result.offsetSeconds)
)
assert.deepEqual(
rebased[1].messages.map((message: { timestamp: number }) => message.timestamp),
privateTimestamps.map((timestamp) => timestamp + result.offsetSeconds)
)
assert.equal(rebased[0].chatlab.exportedAt, Math.floor(now.getTime() / 1000))
assert.equal(rebased[1].chatlab.exportedAt, Math.floor(now.getTime() / 1000))
assert.equal(inputs[0], createDocument(groupTimestamps))
})
it('supports previously published Demo documents without timeline metadata', () => {
const source = sourceTimestamp('2026-12-10', '22:30:00')
const now = new Date('2027-03-10T12:00:00.000Z')
const result = rebaseChatLabDemoDocuments([createDocument([source], { includeTimeline: false })], now)
const output = JSON.parse(result.documents[0])
assert.equal(output.messages[0].timestamp, result.latestTimestamp)
assert.ok(result.latestTimestamp < Math.floor(now.getTime() / 1000))
})
it('rejects inconsistent or malformed Demo timelines before import', () => {
const timestamp = sourceTimestamp('2000-12-10', '22:30:00')
assert.throws(
() =>
rebaseChatLabDemoDocuments([
createDocument([timestamp], { offset: 480 }),
createDocument([timestamp], { offset: 0 }),
]),
/same time zone offset/
)
assert.throws(() => rebaseChatLabDemoDocuments(['{"chatlab":{},"messages":[]}']), /has no messages/)
assert.throws(() => rebaseChatLabDemoDocuments(['not-json']), /not valid JSON/)
})
})
@@ -0,0 +1,133 @@
const SECONDS_PER_MINUTE = 60
const DEFAULT_DEMO_TIME_ZONE_OFFSET_MINUTES = 8 * 60
const MAX_TIME_ZONE_OFFSET_MINUTES = 14 * 60
interface ChatLabDemoTimeline {
timeZoneOffsetMinutes?: unknown
}
interface ChatLabDemoHeader extends Record<string, unknown> {
exportedAt: number
demoTimeline?: ChatLabDemoTimeline
}
interface ChatLabDemoMessage extends Record<string, unknown> {
timestamp: number
}
interface ChatLabDemoDocument extends Record<string, unknown> {
chatlab: ChatLabDemoHeader
messages: ChatLabDemoMessage[]
}
export interface RebasedChatLabDemoDocuments {
documents: string[]
offsetSeconds: number
sourceLatestTimestamp: number
latestTimestamp: number
}
/**
* Rebase official Demo documents as one timeline so their latest message falls
* on yesterday in the importing user's local calendar.
*
* The same offset is applied to every document. This preserves message
* intervals and keeps the group/private story lines aligned.
*/
export function rebaseChatLabDemoDocuments(
jsonDocuments: readonly string[],
now: Date = new Date()
): RebasedChatLabDemoDocuments {
if (jsonDocuments.length === 0) throw new Error('Demo timeline requires at least one document')
if (!Number.isFinite(now.getTime())) throw new Error('Demo timeline requires a valid import time')
const documents = jsonDocuments.map((json, index) => parseDemoDocument(json, index))
const timeZoneOffsetMinutes = resolveTimeZoneOffsetMinutes(documents)
const sourceLatestTimestamp = Math.max(
...documents.flatMap((document) => document.messages.map((message) => message.timestamp))
)
// 源时间戳使用固定时区生成;先还原其中的墙上时间,再映射到用户本地的昨天。
const sourceLatestWallTime = new Date((sourceLatestTimestamp + timeZoneOffsetMinutes * SECONDS_PER_MINUTE) * 1000)
const targetLatestTime = new Date(now)
targetLatestTime.setDate(targetLatestTime.getDate() - 1)
targetLatestTime.setHours(
sourceLatestWallTime.getUTCHours(),
sourceLatestWallTime.getUTCMinutes(),
sourceLatestWallTime.getUTCSeconds(),
0
)
const latestTimestamp = Math.floor(targetLatestTime.getTime() / 1000)
const offsetSeconds = latestTimestamp - sourceLatestTimestamp
const exportedAt = Math.floor(now.getTime() / 1000)
for (const document of documents) {
document.chatlab.exportedAt = exportedAt
for (const message of document.messages) {
message.timestamp += offsetSeconds
}
}
return {
documents: documents.map((document) => JSON.stringify(document)),
offsetSeconds,
sourceLatestTimestamp,
latestTimestamp,
}
}
function parseDemoDocument(json: string, index: number): ChatLabDemoDocument {
let value: unknown
try {
value = JSON.parse(json)
} catch {
throw new Error(`Demo document ${index + 1} is not valid JSON`)
}
if (!isRecord(value) || !isRecord(value.chatlab) || !Array.isArray(value.messages)) {
throw new Error(`Demo document ${index + 1} is not a ChatLab JSON document`)
}
if (value.messages.length === 0) {
throw new Error(`Demo document ${index + 1} has no messages`)
}
const messages = value.messages.map((message, messageIndex) => {
if (!isRecord(message) || !isUnixSeconds(message.timestamp)) {
throw new Error(`Demo document ${index + 1} has an invalid timestamp at message ${messageIndex + 1}`)
}
return message as ChatLabDemoMessage
})
return {
...value,
chatlab: value.chatlab as ChatLabDemoHeader,
messages,
}
}
function resolveTimeZoneOffsetMinutes(documents: readonly ChatLabDemoDocument[]): number {
const offsets = new Set<number>()
for (const document of documents) {
const configured = document.chatlab.demoTimeline?.timeZoneOffsetMinutes
const offset = configured ?? DEFAULT_DEMO_TIME_ZONE_OFFSET_MINUTES
if (typeof offset !== 'number' || !Number.isInteger(offset) || Math.abs(offset) > MAX_TIME_ZONE_OFFSET_MINUTES) {
throw new Error('Demo timeline has an invalid time zone offset')
}
offsets.add(offset)
}
if (offsets.size !== 1) {
throw new Error('Demo documents must use the same time zone offset')
}
return offsets.values().next().value as number
}
function isUnixSeconds(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -224,6 +224,37 @@ test('validates ChatLab JSON members and messages', async () => {
}
})
test('accepts the reserved SYSTEM sender without a member entry', async () => {
const root = makeTempDir()
const filePath = path.join(root, 'system-message.json')
fs.writeFileSync(
filePath,
JSON.stringify({
chatlab: { version: CHATLAB_FORMAT_VERSION, exportedAt: 1_711_468_800 },
meta: { name: 'Group chat', platform: 'custom', type: 'group' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{
sender: 'SYSTEM',
accountName: 'System',
timestamp: 1_711_468_800,
type: 80,
content: 'Alice joined the group',
},
],
}),
'utf8'
)
try {
const report = await validateChatLabFile(filePath)
assert.equal(report.valid, true)
assert.equal(report.errorCount, 0)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('requires explicit members in ChatLab JSON', async () => {
const root = makeTempDir()
const filePath = path.join(root, 'missing-members.json')
+2 -1
View File
@@ -13,6 +13,7 @@ const MAX_UNIX_SECONDS = 100_000_000_000
const KNOWN_MESSAGE_TYPES = new Set<number>(
Object.values(MessageType).filter((value): value is number => typeof value === 'number')
)
const SYSTEM_SENDER_ID = 'SYSTEM'
export type ChatLabValidationFormat = 'json' | 'jsonl' | 'unknown'
export type ChatLabValidationSeverity = 'error' | 'warning'
@@ -369,7 +370,7 @@ function finalizeState(
if (state.memberIds.size > 0) {
for (const senderId of state.senderIds) {
if (!state.memberIds.has(senderId)) {
if (senderId !== SYSTEM_SENDER_ID && !state.memberIds.has(senderId)) {
addIssue(
state,
'error',
+1
View File
@@ -30,6 +30,7 @@ export {
type ChatLabValidationSeverity,
type ChatLabValidationStats,
} from './chatlab-validator'
export { rebaseChatLabDemoDocuments, type RebasedChatLabDemoDocuments } from './chatlab-demo-timeline'
// ==================== 全局嗅探器实例 ====================
@@ -12,6 +12,9 @@ const { t } = useI18n()
const router = useRouter()
const sessionStore = useSessionStore()
const settingsStore = useSettingsStore()
const props = withDefaults(defineProps<{ generateSessionIndexes?: boolean }>(), {
generateSessionIndexes: true,
})
const isImporting = ref(false)
const stage = ref<'downloading' | 'importing'>('downloading')
@@ -26,7 +29,8 @@ async function navigateToSession(sessionId: string) {
}
async function handleImportViaService() {
const demoLocale = getChatlabSiteLocalePath(settingsStore.locale) || 'en'
// 当前示例数据只提供中、英文;其他界面语言使用英文示例。
const demoLocale = getChatlabSiteLocalePath(settingsStore.locale) === 'cn' ? 'cn' : 'en'
return useImportService().importDemo(demoLocale, (progress) => {
stage.value = progress.stage
})
@@ -44,15 +48,17 @@ async function handleImport() {
await sessionStore.loadSessions()
sessionStore.selectSession(result.groupSessionId)
try {
const gapThreshold = getSessionGapThreshold()
const sessionIndexService = useSessionIndexService()
await sessionIndexService.generate(result.groupSessionId, gapThreshold)
if (result.privateSessionIds?.length) {
await Promise.all(result.privateSessionIds.map((id) => sessionIndexService.generate(id, gapThreshold)))
if (props.generateSessionIndexes) {
try {
const gapThreshold = getSessionGapThreshold()
const sessionIndexService = useSessionIndexService()
await sessionIndexService.generate(result.groupSessionId, gapThreshold)
if (result.privateSessionIds?.length) {
await Promise.all(result.privateSessionIds.map((id) => sessionIndexService.generate(id, gapThreshold)))
}
} catch (e) {
console.error('自动生成会话索引失败:', e)
}
} catch (e) {
console.error('自动生成会话索引失败:', e)
}
await navigateToSession(result.groupSessionId)
+1 -1
View File
@@ -13,7 +13,7 @@ import ApiImportCard from './components/import/ApiImportCard.vue'
import CliImportCard from './components/import/CliImportCard.vue'
import ChangelogModal from '@/components/home/ChangelogModal.vue'
import HomeFooter from '@/components/home/HomeFooter.vue'
import DemoImportButton from './components/DemoImportButton.vue'
import DemoImportButton from '@/components/home/DemoImportButton.vue'
const { t, locale } = useI18n()
const sessionStore = useSessionStore()
+131
View File
@@ -20,6 +20,37 @@ function createFile(name = 'fixture.json', content = '{}', type = 'application/j
} as File
}
function demoTimestamp(date: string, time: string): number {
return Math.floor(new Date(`${date}T${time}+08:00`).getTime() / 1000)
}
function createDemoDocument(name: string, timestamps: number[]): string {
return JSON.stringify({
chatlab: {
version: '0.0.2',
exportedAt: demoTimestamp('2000-01-01', '00:00:00'),
generator: 'ChatLab Demo',
description: 'x'.repeat(128),
demoTimeline: {
version: 1,
mode: 'relative',
referenceYear: 2000,
timeZoneOffsetMinutes: 480,
},
},
meta: { name, platform: 'qq', type: 'private' },
members: [],
messages: timestamps.map((timestamp, index) => ({
sender: '1',
accountName: 'Demo',
timestamp,
type: 0,
platformMessageId: `${name}-${index}`,
content: 'demo',
})),
})
}
describe('BrowserImportAdapter', () => {
it('forwards detection, supported formats, import progress, and the result through RPC', async () => {
const requests: WebRuntimeTaskType[] = []
@@ -94,6 +125,106 @@ describe('BrowserImportAdapter', () => {
await assert.rejects(adapter.incrementalImport('session-one', createFile()), /not available in Web WASM/i)
})
it('downloads and imports the four localized demo files through the browser runtime', async () => {
const requestedUrls: string[] = []
const importedFiles: string[] = []
const importedDocuments: any[] = []
const sourceLatest = demoTimestamp('2000-12-10', '22:30:00')
const documents = [
createDemoDocument('group', [demoTimestamp('2000-02-01', '09:00:00'), sourceLatest]),
createDemoDocument('private-a', [demoTimestamp('2000-02-01', '08:30:00')]),
createDemoDocument('private-b', [demoTimestamp('2000-06-01', '10:00:00')]),
createDemoDocument('private-c', [demoTimestamp('2000-12-09', '21:00:00')]),
]
const now = new Date('2026-07-25T04:00:00.000Z')
let downloadIndex = 0
const fetchDemo: typeof fetch = async function (this: unknown, input) {
assert.equal(this, globalThis)
requestedUrls.push(String(input))
return new Response(documents[downloadIndex++], {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
const rpc = {
async request<T extends WebRuntimeTaskType>(
type: T,
payload: WebRuntimeTaskPayload<T>
): Promise<WebRuntimeTaskResult<T>> {
if (type !== 'import.start') throw new Error(`Unexpected task: ${type}`)
const source = (payload as WebRuntimeTaskPayload<'import.start'>).source as File
importedFiles.push(source.name)
importedDocuments.push(JSON.parse(await source.text()))
return {
sessionId: `session-${importedFiles.length}`,
formatId: 'chatlab',
messageCount: 10,
memberCount: 2,
skippedCount: 0,
} as WebRuntimeTaskResult<T>
},
dispose: () => undefined,
}
const adapter = new BrowserImportAdapter(rpc, fetchDemo, () => now)
const progress: string[] = []
const result = await adapter.importDemo('cn', (event) => progress.push(event.stage))
assert.deepEqual(requestedUrls, [
'/api/demo/cn/demo-group.json',
'/api/demo/cn/demo-private-A-cuilan.json',
'/api/demo/cn/demo-private-B-wukong.json',
'/api/demo/cn/demo-private-C-spider.json',
])
assert.deepEqual(importedFiles, [
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
])
const expectedLatest = new Date(now)
expectedLatest.setDate(expectedLatest.getDate() - 1)
expectedLatest.setHours(22, 30, 0, 0)
const latestTimestamp = Math.floor(expectedLatest.getTime() / 1000)
const offset = latestTimestamp - sourceLatest
assert.equal(importedDocuments[0].messages[1].timestamp, latestTimestamp)
assert.equal(importedDocuments[1].messages[0].timestamp, demoTimestamp('2000-02-01', '08:30:00') + offset)
assert.ok(importedDocuments.every((document) => document.chatlab.exportedAt === Math.floor(now.getTime() / 1000)))
assert.deepEqual(progress, [
'downloading',
'downloading',
'downloading',
'downloading',
'importing',
'importing',
'importing',
'importing',
])
assert.deepEqual(result, {
success: true,
groupSessionId: 'session-1',
privateSessionIds: ['session-2', 'session-3', 'session-4'],
})
})
it('returns a failed demo result without importing when a download fails', async () => {
let importRequests = 0
const rpc = {
async request<T extends WebRuntimeTaskType>(): Promise<WebRuntimeTaskResult<T>> {
importRequests += 1
throw new Error('Import should not start')
},
dispose: () => undefined,
}
const adapter = new BrowserImportAdapter(rpc, async () => new Response(null, { status: 503 }))
const result = await adapter.importDemo('en')
assert.equal(result.success, false)
assert.match(result.error ?? '', /HTTP 503/)
assert.equal(importRequests, 0)
})
it('forwards browser-safe format identifiers to the worker runtime', async () => {
const requestedFormats: string[] = []
const rpc = {
+103 -19
View File
@@ -1,4 +1,6 @@
import type { BrowserImportFormatId, BrowserParseSource, RpcProgressPayload } from '@openchatlab/web-runtime'
import { rebaseChatLabDemoDocuments } from '@openchatlab/parser/browser'
import { reportRuntimeLog } from '@/services/log-report'
import type { BrowserRuntimeRpcPort } from '../browser-runtime/types'
import type {
DemoImportResult,
@@ -14,10 +16,22 @@ import type {
PreparedImportSourceResult,
} from './types'
const DEMO_BASE_URL = '/api/demo'
const DEMO_FILES = [
'demo-group.json',
'demo-private-A-cuilan.json',
'demo-private-B-wukong.json',
'demo-private-C-spider.json',
] as const
export class BrowserImportAdapter implements ImportAdapter {
private activeImport: AbortController | undefined
constructor(private readonly rpc: BrowserRuntimeRpcPort) {}
constructor(
private readonly rpc: BrowserRuntimeRpcPort,
private readonly fetchDemo: typeof fetch = fetch,
private readonly now: () => Date = () => new Date()
) {}
async importFile(
file: File | string,
@@ -35,22 +49,7 @@ export class BrowserImportAdapter implements ImportAdapter {
const controller = new AbortController()
this.activeImport = controller
try {
const result = await this.rpc.request(
'import.start',
{ source: file as BrowserParseSource, formatId, chatIndex: options?.chatIndex },
{
signal: controller.signal,
onProgress: (progress) => onProgress?.(mapImportProgress(progress)),
}
)
return {
success: true,
sessionId: result.sessionId,
importMode: 'created',
newMessageCount: result.messageCount,
messageCount: result.messageCount,
memberCount: result.memberCount,
}
return await this.importBrowserFile(file, formatId, options?.chatIndex, controller.signal, onProgress)
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
} finally {
@@ -94,8 +93,68 @@ export class BrowserImportAdapter implements ImportAdapter {
return unsupported('Prepared import sources')
}
importDemo(_locale: string, _onProgress?: (progress: DemoProgress) => void): Promise<DemoImportResult> {
return unsupported('Demo import')
async importDemo(locale: string, onProgress?: (progress: DemoProgress) => void): Promise<DemoImportResult> {
if (this.activeImport) return { success: false, error: 'Another Web WASM import is already running' }
const controller = new AbortController()
this.activeImport = controller
reportRuntimeLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import started' })
try {
const downloaded: Array<{ filename: string; contentType: string; json: string }> = []
for (const filename of DEMO_FILES) {
onProgress?.({ stage: 'downloading' })
const response = await this.fetchDemo.call(globalThis, `${DEMO_BASE_URL}/${locale}/${filename}`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(`Download demo failed (${filename}): HTTP ${response.status}`)
const content = await response.arrayBuffer()
if (content.byteLength < 100) throw new Error(`Downloaded demo file is too small (${filename})`)
downloaded.push({
filename,
contentType: response.headers.get('content-type') || 'application/json',
json: new TextDecoder().decode(content),
})
}
const rebased = rebaseChatLabDemoDocuments(
downloaded.map(({ json }) => json),
this.now()
)
const files = rebased.documents.map(
(json, index) => new File([json], downloaded[index].filename, { type: downloaded[index].contentType })
)
const sessionIds: string[] = []
for (const file of files) {
onProgress?.({ stage: 'importing' })
const result = await this.importBrowserFile(file, 'chatlab', undefined, controller.signal)
if (!result.success || !result.sessionId) {
throw new Error(result.error || `Failed to import demo: ${file.name}`)
}
sessionIds.push(result.sessionId)
}
const [groupSessionId, ...privateSessionIds] = sessionIds
reportRuntimeLog({
level: 'info',
scope: 'web-wasm-demo',
message: 'Demo import completed',
data: {
sessionCount: sessionIds.length,
offsetSeconds: rebased.offsetSeconds,
latestTimestamp: rebased.latestTimestamp,
},
})
return { success: true, groupSessionId, privateSessionIds }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reportRuntimeLog({ level: 'error', scope: 'web-wasm-demo', message: 'Demo import failed', data: { message } })
return { success: false, error: message }
} finally {
if (this.activeImport === controller) this.activeImport = undefined
}
}
analyzeIncrementalImport(_sessionId: string, _file: File | string): Promise<IncrementalAnalysis> {
@@ -117,6 +176,31 @@ export class BrowserImportAdapter implements ImportAdapter {
): Promise<ImportResult> {
return unsupported('Directory import')
}
private async importBrowserFile(
file: File,
formatId: BrowserImportFormatId | undefined,
chatIndex: number | undefined,
signal: AbortSignal,
onProgress?: (progress: ImportProgress) => void
): Promise<ImportResult> {
const result = await this.rpc.request(
'import.start',
{ source: file as BrowserParseSource, formatId, chatIndex },
{
signal,
onProgress: (progress) => onProgress?.(mapImportProgress(progress)),
}
)
return {
success: true,
sessionId: result.sessionId,
importMode: 'created',
newMessageCount: result.messageCount,
messageCount: result.messageCount,
memberCount: result.memberCount,
}
}
}
function mapImportProgress(progress: RpcProgressPayload): ImportProgress {
+7
View File
@@ -53,5 +53,12 @@ export default defineConfig({
host: '127.0.0.1',
port: 3130,
open: true,
proxy: {
'/api/demo': {
target: 'https://chatlab.fun',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/demo/, '/assets/demo'),
},
},
},
})