From dc20320041ed819c4d484aebf9c37107d589de87 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 26 Jul 2026 00:10:06 +0800 Subject: [PATCH] fix(demo): harden cross-platform demo imports Rebase official Demo timelines against the browser timezone, bound stalled downloads, and roll back partial batches across Desktop, CLI Web, and Web WASM. Document the platform cleanup hook's established best-effort boundary. --- apps/cli/src/http/routes/web/import.ts | 9 +- apps/desktop/main/ipc/demo.ts | 1 + apps/desktop/main/worker/workerManager.ts | 21 +++- .../src/services/demo-import.test.ts | 38 +++++- .../node-runtime/src/services/demo-import.ts | 38 +++++- .../parser/src/chatlab-demo-timeline.test.ts | 9 ++ packages/parser/src/chatlab-demo-timeline.ts | 108 ++++++++++++++++-- src/components/home/DemoImportButton.vue | 2 +- src/services/import/browser.test.ts | 101 ++++++++++++++++ src/services/import/browser.ts | 86 +++++++++++--- src/services/import/fetch.ts | 5 +- .../import/importSourceAdapters.test.ts | 19 +++ 12 files changed, 395 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts index 10c7180f..63e13cbb 100644 --- a/apps/cli/src/http/routes/web/import.ts +++ b/apps/cli/src/http/routes/web/import.ts @@ -407,8 +407,9 @@ export function registerImportRoutes( // ==================== Demo Import ==================== - server.post<{ Body: { locale?: string } }>('/_web/demo/import', async (request, reply) => { - const locale = (request.body as any)?.locale === 'cn' ? 'cn' : 'en' + server.post<{ Body: { locale?: string; timeZone?: string } }>('/_web/demo/import', async (request, reply) => { + const locale = request.body?.locale === 'cn' ? 'cn' : 'en' + const targetTimeZone = typeof request.body?.timeZone === 'string' ? request.body.timeZone : undefined const nativeBinding = resolveNativeBinding() reply.raw.writeHead(200, { @@ -424,7 +425,11 @@ export function registerImportRoutes( const result = await importDemoSessions({ locale, tempPrefix: 'cli-demo-', + targetTimeZone, importFile: (filePath) => streamImport(dbManager, filePath, { nativeBinding }), + deleteSession: (sessionId) => { + dbManager.deleteSessionDatabaseFiles(sessionId) + }, onProgress: (progress) => sendEvent('progress', progress), }) sendEvent('result', result) diff --git a/apps/desktop/main/ipc/demo.ts b/apps/desktop/main/ipc/demo.ts index b97f98dc..a25a7303 100644 --- a/apps/desktop/main/ipc/demo.ts +++ b/apps/desktop/main/ipc/demo.ts @@ -33,6 +33,7 @@ export function registerDemoHandlers(ctx: IpcContext): void { locale: locale === 'cn' ? 'cn' : 'en', tempPrefix: 'desktop-demo-', importFile: (filePath) => worker.streamImport(filePath), + deleteSession: (sessionId) => worker.deleteImportedSession(sessionId), onProgress: sendProgress, }) } diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts index a16b8269..3b8010d8 100644 --- a/apps/desktop/main/worker/workerManager.ts +++ b/apps/desktop/main/worker/workerManager.ts @@ -17,6 +17,10 @@ import { getPathProvider } from '../paths/provider' import { IMPORT_IN_PROGRESS_ERROR_KEY, ImportInProgressError, + contactsService, + deleteSessionCache, + globalInsightService, + peopleRelationshipsService, raiseChatDbCompatibilityGate, withDataDirImportLock, } from '@openchatlab/node-runtime' @@ -546,7 +550,7 @@ async function streamImportUnlocked( kind: 'desktop', }) } catch (error) { - deleteImportedSessionFiles(result.sessionId) + deleteImportedSession(result.sessionId) return { success: false, error: error instanceof Error ? error.message : String(error), @@ -598,7 +602,7 @@ export async function autoImport( kind: 'desktop', }) } catch (error) { - deleteImportedSessionFiles(result.sessionId) + deleteImportedSession(result.sessionId) return { success: false, error: error instanceof Error ? error.message : String(error), @@ -633,7 +637,7 @@ export async function pushImport(sessionId: string, payload: PushImportPayload): kind: 'desktop', }) } catch (error) { - if (outcome.result.created) deleteImportedSessionFiles(sessionId) + if (outcome.result.created) deleteImportedSession(sessionId) throw error } return outcome @@ -661,7 +665,7 @@ export async function analyzePushImport( return sendToWorker('analyzePushImport', { sessionId, payload }) } -function deleteImportedSessionFiles(sessionId: string): void { +export function deleteImportedSession(sessionId: string): void { const dbPath = path.join(getDbDir(), `${sessionId}.db`) for (const suffix of ['', '-wal', '-shm']) { try { @@ -671,6 +675,15 @@ function deleteImportedSessionFiles(sessionId: string): void { /* ignore */ } } + + const cacheDir = getCacheDir() + const userDataDir = getPathProvider().getUserDataDir() + deleteSessionCache(sessionId, cacheDir) + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + deleteSessionCache(sessionId, contactsService.getContactsFactsCacheDir(userDataDir)) + deleteSessionCache(sessionId, globalInsightService.getGlobalInsightFactsCacheDir(userDataDir)) + globalInsightService.deleteAnnualSummarySnapshots(globalInsightService.getGlobalInsightDir(userDataDir)) + deleteSessionCache(sessionId, peopleRelationshipsService.getPeopleRelationshipsFactsCacheDir(userDataDir)) } /** diff --git a/packages/node-runtime/src/services/demo-import.test.ts b/packages/node-runtime/src/services/demo-import.test.ts index fe9f6bc3..8693da7d 100644 --- a/packages/node-runtime/src/services/demo-import.test.ts +++ b/packages/node-runtime/src/services/demo-import.test.ts @@ -52,6 +52,7 @@ describe('importDemoSessions', () => { const result = await importDemoSessions({ locale: 'cn', tempPrefix: 'demo-service-test-', + targetTimeZone: 'America/Los_Angeles', fetchImpl: async function (this: unknown) { assert.equal(this, globalThis) return new Response(documents[downloadIndex++], { status: 200 }) @@ -63,12 +64,10 @@ describe('importDemoSessions', () => { imported.push({ name: filePath, document }) return { success: true, sessionId: `session-${imported.length}` } }, + deleteSession: async () => {}, }) - const expectedLatest = new Date(now) - expectedLatest.setDate(expectedLatest.getDate() - 1) - expectedLatest.setHours(22, 30, 0, 0) - const latestTimestamp = Math.floor(expectedLatest.getTime() / 1000) + const latestTimestamp = Math.floor(new Date('2026-07-24T05:30:00.000Z').getTime() / 1000) const offset = latestTimestamp - sourceLatest assert.deepEqual(result, { @@ -117,6 +116,7 @@ describe('importDemoSessions', () => { importCount += 1 return { success: true, sessionId: 'unexpected' } }, + deleteSession: async () => {}, }) assert.equal(result.success, false) @@ -124,4 +124,34 @@ describe('importDemoSessions', () => { assert.equal(importCount, 0) assert.equal(progress.at(-1)?.stage, 'error') }) + + it('rolls back sessions imported before a later session fails', async () => { + let downloadIndex = 0 + let importCount = 0 + const rolledBackSessionIds: string[] = [] + const documents = [ + createDemoDocument('group', [sourceTimestamp('2000-12-10', '22:30:00')]), + createDemoDocument('private-a', [sourceTimestamp('2000-12-09', '21:00:00')]), + createDemoDocument('private-b', [sourceTimestamp('2000-12-08', '21:00:00')]), + createDemoDocument('private-c', [sourceTimestamp('2000-12-07', '21:00:00')]), + ] + + const result = await importDemoSessions({ + locale: 'cn', + tempPrefix: 'demo-service-rollback-test-', + fetchImpl: async () => new Response(documents[downloadIndex++], { status: 200 }), + importFile: async () => { + importCount += 1 + if (importCount === 3) return { success: false, error: 'third import failed' } + return { success: true, sessionId: `session-${importCount}` } + }, + deleteSession: async (sessionId) => { + rolledBackSessionIds.push(sessionId) + }, + }) + + assert.equal(result.success, false) + assert.match(result.error ?? '', /third import failed/) + assert.deepEqual(rolledBackSessionIds, ['session-2', 'session-1']) + }) }) diff --git a/packages/node-runtime/src/services/demo-import.ts b/packages/node-runtime/src/services/demo-import.ts index 07561514..a52b596c 100644 --- a/packages/node-runtime/src/services/demo-import.ts +++ b/packages/node-runtime/src/services/demo-import.ts @@ -36,9 +36,15 @@ export interface ImportDemoSessionsOptions { locale: 'cn' | 'en' tempPrefix: string importFile: (filePath: string) => Promise + /** + * Rolls back a completed session. Platform cleanup intentionally keeps its established + * best-effort semantics for rare OS-level unlink failures and only surfaces errors its API exposes. + */ + deleteSession: (sessionId: string) => Promise | void onProgress?: (progress: DemoImportProgress) => void fetchImpl?: typeof fetch now?: () => Date + targetTimeZone?: string } /** @@ -48,12 +54,17 @@ export interface ImportDemoSessionsOptions { export async function importDemoSessions(options: ImportDemoSessionsOptions): Promise { const total = DEMO_FILES.length const fetchImpl = options.fetchImpl ?? fetch + const sessionIds: string[] = [] 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 }) + appLogger.info('demo-import', 'Demo import started', { + locale: options.locale, + sessionCount: total, + targetTimeZone: options.targetTimeZone ?? 'runtime-local', + }) const downloaded: string[] = [] for (let index = 0; index < total; index++) { @@ -71,7 +82,7 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr downloaded.push(content.toString('utf8')) } - const rebased = rebaseChatLabDemoDocuments(downloaded, options.now?.() ?? new Date()) + const rebased = rebaseChatLabDemoDocuments(downloaded, options.now?.() ?? new Date(), options.targetTimeZone) const localPaths = rebased.documents.map((document, index) => { const localPath = path.join(createdTempDir, DEMO_FILES[index]) fs.writeFileSync(localPath, document, 'utf8') @@ -84,7 +95,6 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr 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]) @@ -99,7 +109,27 @@ export async function importDemoSessions(options: ImportDemoSessionsOptions): Pr 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) + const rollbackFailures: unknown[] = [] + for (const sessionId of sessionIds.toReversed()) { + try { + await options.deleteSession(sessionId) + } catch (rollbackError) { + rollbackFailures.push(rollbackError) + appLogger.error('demo-import', `Failed to roll back Demo session ${sessionId}`, rollbackError) + } + } + + if (sessionIds.length > 0 && rollbackFailures.length === 0) { + appLogger.info('demo-import', 'Rolled back partially imported Demo sessions', { + sessionCount: sessionIds.length, + }) + } + + const originalMessage = error instanceof Error ? error.message : String(error) + const message = + rollbackFailures.length === 0 + ? originalMessage + : `${originalMessage} (${rollbackFailures.length} Demo session(s) could not be rolled back.)` options.onProgress?.({ stage: 'error', current: 0, total, message }) appLogger.error('demo-import', 'Demo import failed', error) return { success: false, error: message } diff --git a/packages/parser/src/chatlab-demo-timeline.test.ts b/packages/parser/src/chatlab-demo-timeline.test.ts index be24e53b..cecc1a59 100644 --- a/packages/parser/src/chatlab-demo-timeline.test.ts +++ b/packages/parser/src/chatlab-demo-timeline.test.ts @@ -78,6 +78,15 @@ describe('rebaseChatLabDemoDocuments', () => { assert.ok(result.latestTimestamp < Math.floor(now.getTime() / 1000)) }) + it('uses an explicit importing-user timezone instead of the runtime timezone', () => { + const source = sourceTimestamp('2000-12-10', '22:30:00') + const now = new Date('2026-07-25T04:00:00.000Z') + + const result = rebaseChatLabDemoDocuments([createDocument([source])], now, 'America/Los_Angeles') + + assert.equal(result.latestTimestamp, Math.floor(new Date('2026-07-24T05:30:00.000Z').getTime() / 1000)) + }) + it('rejects inconsistent or malformed Demo timelines before import', () => { const timestamp = sourceTimestamp('2000-12-10', '22:30:00') diff --git a/packages/parser/src/chatlab-demo-timeline.ts b/packages/parser/src/chatlab-demo-timeline.ts index db18c2ec..3a969a0a 100644 --- a/packages/parser/src/chatlab-demo-timeline.ts +++ b/packages/parser/src/chatlab-demo-timeline.ts @@ -36,7 +36,8 @@ export interface RebasedChatLabDemoDocuments { */ export function rebaseChatLabDemoDocuments( jsonDocuments: readonly string[], - now: Date = new Date() + now: Date = new Date(), + targetTimeZone?: string ): 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') @@ -47,18 +48,9 @@ export function rebaseChatLabDemoDocuments( ...documents.flatMap((document) => document.messages.map((message) => message.timestamp)) ) - // 源时间戳使用固定时区生成;先还原其中的墙上时间,再映射到用户本地的昨天。 + // Restore the source wall-clock time before mapping it to yesterday in the user's local calendar. 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 latestTimestamp = resolveTargetLatestTimestamp(now, sourceLatestWallTime, targetTimeZone) const offsetSeconds = latestTimestamp - sourceLatestTimestamp const exportedAt = Math.floor(now.getTime() / 1000) @@ -77,6 +69,98 @@ export function rebaseChatLabDemoDocuments( } } +function resolveTargetLatestTimestamp(now: Date, sourceWallTime: Date, targetTimeZone?: string): number { + if (!targetTimeZone) { + const targetLatestTime = new Date(now) + targetLatestTime.setDate(targetLatestTime.getDate() - 1) + targetLatestTime.setHours( + sourceWallTime.getUTCHours(), + sourceWallTime.getUTCMinutes(), + sourceWallTime.getUTCSeconds(), + 0 + ) + return Math.floor(targetLatestTime.getTime() / 1000) + } + + let formatter: Intl.DateTimeFormat + try { + formatter = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { + timeZone: targetTimeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }) + } catch { + throw new Error('Demo timeline has an invalid target time zone') + } + + const currentDate = getZonedDateTimeParts(now, formatter) + const yesterday = new Date(Date.UTC(currentDate.year, currentDate.month - 1, currentDate.day - 1)) + const targetParts: ZonedDateTimeParts = { + year: yesterday.getUTCFullYear(), + month: yesterday.getUTCMonth() + 1, + day: yesterday.getUTCDate(), + hour: sourceWallTime.getUTCHours(), + minute: sourceWallTime.getUTCMinutes(), + second: sourceWallTime.getUTCSeconds(), + } + + return Math.floor(zonedWallTimeToUnixMillis(targetParts, formatter) / 1000) +} + +interface ZonedDateTimeParts { + year: number + month: number + day: number + hour: number + minute: number + second: number +} + +function getZonedDateTimeParts(date: Date, formatter: Intl.DateTimeFormat): ZonedDateTimeParts { + const values = new Map(formatter.formatToParts(date).map((part) => [part.type, part.value])) + const read = (type: keyof ZonedDateTimeParts) => Number(values.get(type)) + const parts: ZonedDateTimeParts = { + year: read('year'), + month: read('month'), + day: read('day'), + hour: read('hour'), + minute: read('minute'), + second: read('second'), + } + if (Object.values(parts).some((value) => !Number.isInteger(value))) { + throw new Error('Demo timeline could not resolve the target time zone') + } + return parts +} + +function zonedWallTimeToUnixMillis(target: ZonedDateTimeParts, formatter: Intl.DateTimeFormat): number { + const targetAsUtc = dateTimePartsToUtcMillis(target) + let candidate = targetAsUtc + + // Intl maps timestamps to zoned wall-clock time; iterative corrections resolve the inverse mapping. + for (let attempt = 0; attempt < 3; attempt++) { + const actual = getZonedDateTimeParts(new Date(candidate), formatter) + const correction = targetAsUtc - dateTimePartsToUtcMillis(actual) + candidate += correction + if (correction === 0) return candidate + } + + const actual = getZonedDateTimeParts(new Date(candidate), formatter) + if (dateTimePartsToUtcMillis(actual) !== targetAsUtc) { + throw new Error('Demo timeline target wall time does not exist in the selected time zone') + } + return candidate +} + +function dateTimePartsToUtcMillis(parts: ZonedDateTimeParts): number { + return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) +} + function parseDemoDocument(json: string, index: number): ChatLabDemoDocument { let value: unknown try { diff --git a/src/components/home/DemoImportButton.vue b/src/components/home/DemoImportButton.vue index b6ecc559..f2f2c20d 100644 --- a/src/components/home/DemoImportButton.vue +++ b/src/components/home/DemoImportButton.vue @@ -29,7 +29,7 @@ async function navigateToSession(sessionId: string) { } async function handleImportViaService() { - // 当前示例数据只提供中、英文;其他界面语言使用英文示例。 + // Demo assets are available in Chinese and English; other interface locales use the English version. const demoLocale = getChatlabSiteLocalePath(settingsStore.locale) === 'cn' ? 'cn' : 'en' return useImportService().importDemo(demoLocale, (progress) => { stage.value = progress.stage diff --git a/src/services/import/browser.test.ts b/src/services/import/browser.test.ts index 384f3e32..6305c8a4 100644 --- a/src/services/import/browser.test.ts +++ b/src/services/import/browser.test.ts @@ -225,6 +225,107 @@ describe('BrowserImportAdapter', () => { assert.equal(importRequests, 0) }) + it('times out a stalled demo response body and releases the active import', async () => { + let importRequests = 0 + const rpc = { + async request(): Promise> { + importRequests += 1 + throw new Error('Import should not start') + }, + dispose: () => undefined, + } + const fetchDemo: typeof fetch = async (_input, init) => { + const signal = init?.signal + return new Response( + new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => controller.error(signal.reason), { once: true }) + }, + }) + ) + } + const adapter = new BrowserImportAdapter(rpc, fetchDemo, () => new Date(), 10) + const pending = adapter.importDemo('en') + let watchdog: ReturnType | undefined + + try { + const result = await Promise.race([ + pending, + new Promise<'still-pending'>((resolve) => { + watchdog = setTimeout(() => resolve('still-pending'), 100) + }), + ]) + if (result === 'still-pending') assert.fail('Demo download did not time out') + + assert.equal(result.success, false) + assert.match(result.error ?? '', /timed out/i) + assert.equal(importRequests, 0) + + const retry = await adapter.importDemo('en') + assert.match(retry.error ?? '', /timed out/i) + } finally { + if (watchdog) clearTimeout(watchdog) + adapter.cancelActiveImport() + await pending + } + }) + + it('deletes sessions created earlier in the batch when a later demo import fails', async () => { + const deletedSessionIds: string[] = [] + const reportedErrors: unknown[] = [] + const importError = new Error('third import failed') + let importCount = 0 + const documents = [ + createDemoDocument('group', [demoTimestamp('2000-12-10', '22:30:00')]), + createDemoDocument('private-a', [demoTimestamp('2000-12-09', '21:00:00')]), + createDemoDocument('private-b', [demoTimestamp('2000-12-08', '20:00:00')]), + createDemoDocument('private-c', [demoTimestamp('2000-12-07', '19:00:00')]), + ] + let downloadIndex = 0 + const rpc = { + async request( + type: T, + payload: WebRuntimeTaskPayload + ): Promise> { + if (type === 'import.start') { + importCount += 1 + if (importCount === 3) throw importError + return { + sessionId: `session-${importCount}`, + formatId: 'chatlab', + messageCount: 1, + memberCount: 1, + skippedCount: 0, + } as WebRuntimeTaskResult + } + if (type === 'session.delete') { + deletedSessionIds.push((payload as WebRuntimeTaskPayload<'session.delete'>).sessionId) + return { deleted: true } as WebRuntimeTaskResult + } + throw new Error(`Unexpected task: ${type}`) + }, + dispose: () => undefined, + } + const adapter = new BrowserImportAdapter( + rpc, + async () => new Response(documents[downloadIndex++], { status: 200 }), + () => new Date('2026-07-25T04:00:00.000Z'), + 60_000, + (event) => { + if (event.level === 'error' && event.message === 'Demo import failed') { + reportedErrors.push(event.data?.error) + } + } + ) + + const result = await adapter.importDemo('en') + + assert.equal(result.success, false) + assert.match(result.error ?? '', /third import failed/) + assert.deepEqual(deletedSessionIds, ['session-2', 'session-1']) + assert.deepEqual(reportedErrors, [importError]) + }) + 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 046f3f8f..4bc539df 100644 --- a/src/services/import/browser.ts +++ b/src/services/import/browser.ts @@ -17,6 +17,7 @@ import type { } from './types' const DEMO_BASE_URL = '/api/demo' +const DEMO_DOWNLOAD_TIMEOUT_MS = 60_000 const DEMO_FILES = [ 'demo-group.json', 'demo-private-A-cuilan.json', @@ -30,7 +31,9 @@ export class BrowserImportAdapter implements ImportAdapter { constructor( private readonly rpc: BrowserRuntimeRpcPort, private readonly fetchDemo: typeof fetch = fetch, - private readonly now: () => Date = () => new Date() + private readonly now: () => Date = () => new Date(), + private readonly demoDownloadTimeoutMs = DEMO_DOWNLOAD_TIMEOUT_MS, + private readonly reportLog: typeof reportRuntimeLog = reportRuntimeLog ) {} async importFile( @@ -98,22 +101,17 @@ export class BrowserImportAdapter implements ImportAdapter { const controller = new AbortController() this.activeImport = controller - reportRuntimeLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import started' }) + this.reportLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import started' }) + const sessionIds: string[] = [] 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})`) + const { content, contentType } = await this.downloadDemoFile(locale, filename, controller) downloaded.push({ filename, - contentType: response.headers.get('content-type') || 'application/json', + contentType, json: new TextDecoder().decode(content), }) } @@ -126,7 +124,6 @@ export class BrowserImportAdapter implements ImportAdapter { (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) @@ -137,7 +134,7 @@ export class BrowserImportAdapter implements ImportAdapter { } const [groupSessionId, ...privateSessionIds] = sessionIds - reportRuntimeLog({ + this.reportLog({ level: 'info', scope: 'web-wasm-demo', message: 'Demo import completed', @@ -150,13 +147,52 @@ export class BrowserImportAdapter implements ImportAdapter { 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 } + const rollbackFailureCount = await this.rollbackDemoSessions(sessionIds) + this.reportLog({ + level: 'error', + scope: 'web-wasm-demo', + message: 'Demo import failed', + data: { message, error }, + }) + return { + success: false, + error: + rollbackFailureCount > 0 + ? `${message} (${rollbackFailureCount} imported Demo sessions could not be removed)` + : message, + } } finally { if (this.activeImport === controller) this.activeImport = undefined } } + private async downloadDemoFile( + locale: string, + filename: (typeof DEMO_FILES)[number], + controller: AbortController + ): Promise<{ content: ArrayBuffer; contentType: string }> { + // Use one controller for user cancellation and per-file timeout so response-body reads are also abortable. + const timeout = setTimeout(() => { + controller.abort(new Error(`Download demo timed out (${filename})`)) + }, this.demoDownloadTimeoutMs) + + try { + 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})`) + return { + content, + contentType: response.headers.get('content-type') || 'application/json', + } + } finally { + clearTimeout(timeout) + } + } + analyzeIncrementalImport(_sessionId: string, _file: File | string): Promise { return unsupported('Incremental import') } @@ -201,6 +237,28 @@ export class BrowserImportAdapter implements ImportAdapter { memberCount: result.memberCount, } } + + private async rollbackDemoSessions(sessionIds: readonly string[]): Promise { + let failureCount = 0 + for (const sessionId of [...sessionIds].reverse()) { + try { + const result = await this.rpc.request('session.delete', { sessionId }) + if (!result.deleted) failureCount += 1 + } catch { + failureCount += 1 + } + } + + if (sessionIds.length > 0) { + this.reportLog({ + level: failureCount > 0 ? 'error' : 'info', + scope: 'web-wasm-demo', + message: failureCount > 0 ? 'Demo import rollback incomplete' : 'Demo import rolled back', + data: { sessionCount: sessionIds.length, failureCount }, + }) + } + return failureCount + } } function mapImportProgress(progress: RpcProgressPayload): ImportProgress { diff --git a/src/services/import/fetch.ts b/src/services/import/fetch.ts index 2e278a0f..d5ef44d3 100644 --- a/src/services/import/fetch.ts +++ b/src/services/import/fetch.ts @@ -151,7 +151,10 @@ export class FetchImportAdapter implements ImportAdapter { fetchWithAuth(`${getBaseUrl()}/demo/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ locale }), + body: JSON.stringify({ + locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), }) .then(async (resp) => { if (!resp.ok || !resp.body) { diff --git a/src/services/import/importSourceAdapters.test.ts b/src/services/import/importSourceAdapters.test.ts index a4d8dc5c..98f792be 100644 --- a/src/services/import/importSourceAdapters.test.ts +++ b/src/services/import/importSourceAdapters.test.ts @@ -104,4 +104,23 @@ describe('archive import source adapters', () => { assert.equal(requests[1].init?.body, JSON.stringify({ chatId: 'Groups/DM sample' })) assert.equal(requests[2].init?.method, 'DELETE') }) + + it('sends the browser timezone when importing Demo sessions through CLI Web', async () => { + let requestBody: string | undefined + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = init?.body as string | undefined + return new Response('event: result\ndata: {"success":true,"groupSessionId":"group"}\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + }) + }) as typeof fetch + + const adapter = new FetchImportAdapter() + const result = await adapter.importDemo('cn') + + assert.equal(result.success, true) + assert.deepEqual(JSON.parse(requestBody ?? ''), { + locale: 'cn', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + }) })