feat(web): show byte progress while loading generated media

Large media downloads (a 19MB video takes ~60s over the slow runner
tunnel) previously showed only a pulsing placeholder, so the load looked
dead and users gave up. Read the response stream against Content-Length
and render a percentage bar while the blob downloads; fall back to an
indeterminate "Loading …" label when the header is absent, and keep the
instant path for cache hits.
This commit is contained in:
2026-10-01 01:16:00 +08:00
parent 587705dedb
commit 4d8a72c146
5 changed files with 166 additions and 6 deletions
+10 -3
View File
@@ -77,6 +77,7 @@ import type {
import type { AgentFlavor, MessageDeliveryMode } from '@hapi/protocol'
import type { CancelMessageResponse, SteerQueuedMessageResponse } from '@hapi/protocol/schemas'
import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice'
import { readBlobWithProgress, type BlobProgressReporter } from '@/lib/readBlobWithProgress'
export type RetryIndeterminateMessageResponse =
| { status: 'retried' | 'already-queued' | 'retry-unavailable'; localId: string | null }
@@ -719,7 +720,13 @@ export class ApiClient {
return await this.request<FileSearchResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`)
}
async getGeneratedImageBlob(sessionId: string, imageId: string, attempt: number = 0, overrideToken?: string | null): Promise<Blob> {
async getGeneratedImageBlob(
sessionId: string,
imageId: string,
attempt: number = 0,
overrideToken?: string | null,
onProgress?: BlobProgressReporter
): Promise<Blob> {
const headers = new Headers()
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken !== undefined
@@ -739,13 +746,13 @@ export class ApiClient {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
return await this.getGeneratedImageBlob(sessionId, imageId, attempt + 1, refreshed)
return await this.getGeneratedImageBlob(sessionId, imageId, attempt + 1, refreshed, onProgress)
}
}
if (!res.ok) {
throw new ApiError(`HTTP ${res.status}`, res.status, undefined, await res.text().catch(() => undefined))
}
return await res.blob()
return await readBlobWithProgress(res, onProgress)
}
async readSessionFile(sessionId: string, path: string): Promise<FileReadResponse> {
@@ -129,7 +129,7 @@ describe('GeneratedImageCard video fetch', () => {
fireEvent.click(screen.getByRole('button', { name: 'Load video' }))
await waitFor(() => {
expect(getGeneratedImageBlob).toHaveBeenCalledWith('session-1', 'img-1')
expect(getGeneratedImageBlob).toHaveBeenCalledWith('session-1', 'img-1', 0, undefined, expect.any(Function))
})
})
@@ -151,6 +151,29 @@ describe('GeneratedImageCard video fetch', () => {
})
})
it('shows byte progress while an explicitly loaded video downloads', async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const getGeneratedImageBlob = vi.fn(async (
_sessionId: string,
_imageId: string,
_attempt?: number,
_token?: string | null,
onProgress?: (loaded: number, total: number | null) => void
) => {
onProgress?.(5, 10)
await gate
return new Blob(['x'], { type: 'video/mp4' })
})
renderCard({ mimeType: 'video/mp4', getGeneratedImageBlob })
fireEvent.click(screen.getByRole('button', { name: 'Load video' }))
await waitFor(() => expect(screen.getByText('50%')).toBeInTheDocument())
release()
await waitFor(() => expect(document.querySelector('video')).toBeInTheDocument())
})
it('returns an evicted video card to its Load button to bound memory', async () => {
renderVideoCards(['v1', 'v2', 'v3', 'v4'])
const buttons = screen.getAllByRole('button', { name: 'Load video' })
@@ -72,6 +72,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
const [error, setError] = useState<string | null>(null)
const [imageStyle, setImageStyle] = useState<CSSProperties | undefined>(undefined)
const [loadMedia, setLoadMedia] = useState(false)
const [progress, setProgress] = useState<{ loaded: number; total: number | null } | null>(null)
const objectUrlRef = useRef<string | null>(null)
const evictedRef = useRef(false)
const isVideo = isInlineVideoMimeType(props.block.mimeType)
@@ -107,19 +108,35 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
// listener brings this card back to its Load button when evicted.
setError(null)
evictedRef.current = false
setProgress({ loaded: 0, total: null })
const unsubscribe = subscribeGeneratedMediaEviction(mediaKey, () => {
evictedRef.current = true
setObjectUrl(null)
setLoadMedia(false)
setProgress(null)
})
void loadGeneratedMediaObjectUrl(mediaKey, () => ctx.api.getGeneratedImageBlob(ctx.sessionId, props.block.imageId))
void loadGeneratedMediaObjectUrl(mediaKey, () => ctx.api.getGeneratedImageBlob(
ctx.sessionId,
props.block.imageId,
0,
undefined,
(loaded, total) => {
if (!disposed) {
setProgress({ loaded, total })
}
}
))
.then((url) => {
if (!disposed && !evictedRef.current) {
setObjectUrl(url)
}
if (!disposed) {
setProgress(null)
}
})
.catch((err: unknown) => {
if (!disposed) {
setProgress(null)
setError(err instanceof Error ? err.message : 'Failed to load inline media')
}
})
@@ -223,7 +240,28 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) {
{isVideo ? 'Load video' : isAudio ? 'Load audio' : 'Prepare download'}
</button>
) : (
<div className="h-48 w-72 max-w-full animate-pulse rounded-xl bg-[var(--app-subtle-bg)]" />
<div className="flex h-48 w-72 max-w-full flex-col items-center justify-center gap-3 rounded-xl bg-[var(--app-subtle-bg)] px-6">
{progress?.total ? (
<>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-border)]">
<div
className="h-full rounded-full bg-[var(--app-link)] transition-[width] duration-200"
style={{ width: `${Math.min(100, Math.round((progress.loaded / progress.total) * 100))}%` }}
/>
</div>
<span className="text-xs tabular-nums text-[var(--app-hint)]">
{Math.min(100, Math.round((progress.loaded / progress.total) * 100))}%
</span>
</>
) : (
<>
<div className="h-1.5 w-full animate-pulse overflow-hidden rounded-full bg-[var(--app-border)]" />
<span className="text-xs text-[var(--app-hint)]">
{isVideo ? 'Loading video…' : isAudio ? 'Loading audio…' : 'Preparing download…'}
</span>
</>
)}
</div>
)}
</div>
)
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { readBlobWithProgress } from './readBlobWithProgress'
/** jsdom Blob lacks arrayBuffer(); read through FileReader instead. */
function blobBytes(blob: Blob): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(blob)
})
}
function streamResponse(chunks: Uint8Array[], total?: number): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk)
}
controller.close()
}
})
const headers = new Headers({ 'content-type': 'video/mp4' })
if (total !== undefined) {
headers.set('content-length', String(total))
}
return new Response(body, { headers })
}
describe('readBlobWithProgress', () => {
it('reports byte progress and preserves the content type', async () => {
const progress: Array<[number, number | null]> = []
const blob = await readBlobWithProgress(
streamResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])], 5),
(loaded, total) => progress.push([loaded, total])
)
expect(progress).toEqual([[2, 5], [5, 5]])
expect(blob.type).toBe('video/mp4')
expect(await blobBytes(blob)).toEqual(new Uint8Array([1, 2, 3, 4, 5]))
})
it('reports a null total when content-length is missing', async () => {
const progress: Array<[number, number | null]> = []
const blob = await readBlobWithProgress(
streamResponse([new Uint8Array([7])]),
(loaded, total) => progress.push([loaded, total])
)
expect(progress).toEqual([[1, null]])
expect(await blobBytes(blob)).toEqual(new Uint8Array([7]))
})
it('reads a plain blob when no reporter is given', async () => {
const response = new Response(new Uint8Array([9, 9]))
const blob = await readBlobWithProgress(response)
expect(blob.size).toBe(2)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type BlobProgressReporter = (loaded: number, total: number | null) => void
/**
* Read a response body into a Blob while reporting byte progress.
*
* Large generated media (videos) can take a minute over a slow runner
* tunnel; `res.blob()` gives no feedback, so callers can surface a
* determinate progress bar when the hub sent Content-Length.
*/
export async function readBlobWithProgress(response: Response, onProgress?: BlobProgressReporter): Promise<Blob> {
if (!onProgress || !response.body) {
return await response.blob()
}
const header = response.headers.get('content-length')
const parsed = header === null ? Number.NaN : Number(header)
const total = Number.isFinite(parsed) && parsed > 0 ? parsed : null
const reader = response.body.getReader()
const chunks: BlobPart[] = []
let loaded = 0
for (;;) {
const { value, done } = await reader.read()
if (done) break
if (!value) continue
chunks.push(value as unknown as BlobPart)
loaded += value.byteLength
onProgress(loaded, total)
}
const type = response.headers.get('content-type')
return new Blob(chunks, type ? { type } : undefined)
}