From 4d8a72c146888ae4593dda81efd03f334027faf2 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Thu, 1 Oct 2026 01:16:00 +0800 Subject: [PATCH] feat(web): show byte progress while loading generated media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/api/client.ts | 13 +++- .../ToolMessage.generatedMedia.test.tsx | 25 +++++++- .../AssistantChat/messages/ToolMessage.tsx | 42 ++++++++++++- web/src/lib/readBlobWithProgress.test.ts | 59 +++++++++++++++++++ web/src/lib/readBlobWithProgress.ts | 33 +++++++++++ 5 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/readBlobWithProgress.test.ts create mode 100644 web/src/lib/readBlobWithProgress.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 12d95564..dce3830f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`) } - async getGeneratedImageBlob(sessionId: string, imageId: string, attempt: number = 0, overrideToken?: string | null): Promise { + async getGeneratedImageBlob( + sessionId: string, + imageId: string, + attempt: number = 0, + overrideToken?: string | null, + onProgress?: BlobProgressReporter + ): Promise { 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 { diff --git a/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx b/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx index 4774589a..4b3bbe9c 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.generatedMedia.test.tsx @@ -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((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' }) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 285138ed..00649c4a 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -72,6 +72,7 @@ export function GeneratedImageCard(props: { block: GeneratedImageBlock }) { const [error, setError] = useState(null) const [imageStyle, setImageStyle] = useState(undefined) const [loadMedia, setLoadMedia] = useState(false) + const [progress, setProgress] = useState<{ loaded: number; total: number | null } | null>(null) const objectUrlRef = useRef(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'} ) : ( -
+
+ {progress?.total ? ( + <> +
+
+
+ + {Math.min(100, Math.round((progress.loaded / progress.total) * 100))}% + + + ) : ( + <> +
+ + {isVideo ? 'Loading video…' : isAudio ? 'Loading audio…' : 'Preparing download…'} + + + )} +
)}
) diff --git a/web/src/lib/readBlobWithProgress.test.ts b/web/src/lib/readBlobWithProgress.test.ts new file mode 100644 index 00000000..d2a950cf --- /dev/null +++ b/web/src/lib/readBlobWithProgress.test.ts @@ -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 { + 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({ + 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) + }) +}) diff --git a/web/src/lib/readBlobWithProgress.ts b/web/src/lib/readBlobWithProgress.ts new file mode 100644 index 00000000..1fb4dcb0 --- /dev/null +++ b/web/src/lib/readBlobWithProgress.ts @@ -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 { + 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) +}