fix(session-export): preserve streamed browser downloads

This commit is contained in:
NI0317
2026-08-12 18:44:04 +08:00
parent 8c7dab8755
commit 785f41daed
35 changed files with 226 additions and 136 deletions
@@ -12,7 +12,7 @@ export interface SessionExportDialogInjected {
}
export type SessionExportDialogProps =
PropsRuntime<'conversation.session.header.actions'>
PropsRuntime<'conversation.session.header.utilities'>
& PropsLocale<typeof NS>
& InjectFace<SessionExportDialogInjected>
@@ -1,4 +1,3 @@
/* The 111 px design width is a floor so translated labels do not clip. */
.sessionLogButton {
display: inline-flex;
align-items: center;
@@ -18,7 +18,7 @@ export interface SessionExportDownloadState {
}
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
type Save = (blob: Blob, filename: string) => void
type Save = (url: string, filename: string) => void
const INITIAL: SessionExportDownloadState = { bySession: {} }
@@ -32,17 +32,15 @@ export function sessionLogZipFilename(sessionId: SessionId): string {
}
/**
* Trigger a browser save without copying the response blob.
* @param blob - complete ZIP response body.
* Hand a Host download URL to the browser download manager.
* @param url - same-origin Host download URL.
* @param filename - browser download filename.
*/
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
export function downloadUrl(url: string, filename: string): void {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
}
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
@@ -69,7 +67,7 @@ export class SessionExportDownloadController {
*/
constructor(
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
private readonly save: Save = downloadBlob,
private readonly save: Save = downloadUrl,
) {}
/**
@@ -89,15 +87,6 @@ export class SessionExportDownloadController {
return done
}
/**
* Present a command failure without issuing an HTTP request.
* @param sessionId - Session whose modal reports the failure.
* @param error - stable command failure text.
*/
fail(sessionId: SessionId, error: string): void {
this.publish(sessionId, { open: true, status: 'error', error })
}
/**
* Close one Session's dialog without cancelling an in-flight browser download.
* @param sessionId - Session whose modal closes.
@@ -125,12 +114,12 @@ export class SessionExportDownloadController {
const url = new URL('/api/session.export', hostBase())
url.searchParams.set('sessionId', sessionId)
url.searchParams.set('includeDescendants', 'true')
const response = await this.fetcher(url, { signal })
const response = await this.fetcher(url, { method: 'HEAD', signal })
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
}
this.save(await response.blob(), sessionLogZipFilename(sessionId))
this.save(url.toString(), sessionLogZipFilename(sessionId))
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
this.publish(sessionId, { open, status: 'success', error: null })
} catch (error: unknown) {