diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts index 50e3444e..10c7180f 100644 --- a/apps/cli/src/http/routes/web/import.ts +++ b/apps/cli/src/http/routes/web/import.ts @@ -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() }) } diff --git a/apps/desktop/main/ipc/demo.ts b/apps/desktop/main/ipc/demo.ts index 715a6f8b..b97f98dc 100644 --- a/apps/desktop/main/ipc/demo.ts +++ b/apps/desktop/main/ipc/demo.ts @@ -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 { - 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, + }) } ) } diff --git a/apps/web-wasm/src/pages/HomePage.vue b/apps/web-wasm/src/pages/HomePage.vue index 8fa7a6d8..5de89e28 100644 --- a/apps/web-wasm/src/pages/HomePage.vue +++ b/apps/web-wasm/src/pages/HomePage.vue @@ -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(() => {
+ 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) +} diff --git a/edge-functions/api/demo/demo-proxy.test.js b/edge-functions/api/demo/demo-proxy.test.js new file mode 100644 index 00000000..e5286405 --- /dev/null +++ b/edge-functions/api/demo/demo-proxy.test.js @@ -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) + }) +}) diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index cdee47fc..3e0988a6 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -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' diff --git a/packages/node-runtime/src/services/demo-import.test.ts b/packages/node-runtime/src/services/demo-import.test.ts new file mode 100644 index 00000000..fe9f6bc3 --- /dev/null +++ b/packages/node-runtime/src/services/demo-import.test.ts @@ -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') + }) +}) diff --git a/packages/node-runtime/src/services/demo-import.ts b/packages/node-runtime/src/services/demo-import.ts new file mode 100644 index 00000000..07561514 --- /dev/null +++ b/packages/node-runtime/src/services/demo-import.ts @@ -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 + 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 { + 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) + } + } + } +} diff --git a/packages/parser/browser.ts b/packages/parser/browser.ts index 81be69e8..ed6c66e0 100644 --- a/packages/parser/browser.ts +++ b/packages/parser/browser.ts @@ -24,3 +24,4 @@ export { type WeFlowJsonParseOptions, type WeFlowJsonParseResult, } from './src/browser/weflow' +export { rebaseChatLabDemoDocuments, type RebasedChatLabDemoDocuments } from './src/chatlab-demo-timeline' diff --git a/packages/parser/src/chatlab-demo-timeline.test.ts b/packages/parser/src/chatlab-demo-timeline.test.ts new file mode 100644 index 00000000..be24e53b --- /dev/null +++ b/packages/parser/src/chatlab-demo-timeline.test.ts @@ -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 = { + 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/) + }) +}) diff --git a/packages/parser/src/chatlab-demo-timeline.ts b/packages/parser/src/chatlab-demo-timeline.ts new file mode 100644 index 00000000..db18c2ec --- /dev/null +++ b/packages/parser/src/chatlab-demo-timeline.ts @@ -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 { + exportedAt: number + demoTimeline?: ChatLabDemoTimeline +} + +interface ChatLabDemoMessage extends Record { + timestamp: number +} + +interface ChatLabDemoDocument extends Record { + 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() + + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/parser/src/chatlab-validator.test.ts b/packages/parser/src/chatlab-validator.test.ts index b6b4362b..41bfddb1 100644 --- a/packages/parser/src/chatlab-validator.test.ts +++ b/packages/parser/src/chatlab-validator.test.ts @@ -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') diff --git a/packages/parser/src/chatlab-validator.ts b/packages/parser/src/chatlab-validator.ts index ebacd177..8146d3d8 100644 --- a/packages/parser/src/chatlab-validator.ts +++ b/packages/parser/src/chatlab-validator.ts @@ -13,6 +13,7 @@ const MAX_UNIX_SECONDS = 100_000_000_000 const KNOWN_MESSAGE_TYPES = new Set( 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', diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 564f7b01..8acd1aff 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -30,6 +30,7 @@ export { type ChatLabValidationSeverity, type ChatLabValidationStats, } from './chatlab-validator' +export { rebaseChatLabDemoDocuments, type RebasedChatLabDemoDocuments } from './chatlab-demo-timeline' // ==================== 全局嗅探器实例 ==================== diff --git a/src/pages/home/components/DemoImportButton.vue b/src/components/home/DemoImportButton.vue similarity index 75% rename from src/pages/home/components/DemoImportButton.vue rename to src/components/home/DemoImportButton.vue index fd2e5523..b6ecc559 100644 --- a/src/pages/home/components/DemoImportButton.vue +++ b/src/components/home/DemoImportButton.vue @@ -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) diff --git a/src/pages/home/index.vue b/src/pages/home/index.vue index 57c801e7..9c13e7fd 100644 --- a/src/pages/home/index.vue +++ b/src/pages/home/index.vue @@ -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() diff --git a/src/services/import/browser.test.ts b/src/services/import/browser.test.ts index 65b30b9c..384f3e32 100644 --- a/src/services/import/browser.test.ts +++ b/src/services/import/browser.test.ts @@ -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( + type: T, + payload: WebRuntimeTaskPayload + ): Promise> { + 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 + }, + 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(): Promise> { + 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 = { diff --git a/src/services/import/browser.ts b/src/services/import/browser.ts index 07970622..046f3f8f 100644 --- a/src/services/import/browser.ts +++ b/src/services/import/browser.ts @@ -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 { - return unsupported('Demo import') + async importDemo(locale: string, onProgress?: (progress: DemoProgress) => void): Promise { + 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 { @@ -117,6 +176,31 @@ export class BrowserImportAdapter implements ImportAdapter { ): Promise { return unsupported('Directory import') } + + private async importBrowserFile( + file: File, + formatId: BrowserImportFormatId | undefined, + chatIndex: number | undefined, + signal: AbortSignal, + onProgress?: (progress: ImportProgress) => void + ): Promise { + 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 { diff --git a/vite.web-wasm.config.mts b/vite.web-wasm.config.mts index 711798c1..3003de50 100644 --- a/vite.web-wasm.config.mts +++ b/vite.web-wasm.config.mts @@ -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'), + }, + }, }, })