mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(demo): add rolling cross-platform imports
This commit is contained in:
1 parent
e9004ca71f
commit
9e7c76fb89
19 files changed
+859
-174
No files matched your search
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
type ChatLabValidationSeverity,
|
||||
type ChatLabValidationStats,
|
||||
} from './chatlab-validator'
|
||||
export { rebaseChatLabDemoDocuments, type RebasedChatLabDemoDocuments } from './chatlab-demo-timeline'
|
||||
|
||||
// ==================== 全局嗅探器实例 ====================
|
||||
|
||||
|
||||
+15
-9
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user