mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-06 18:39:47 +00:00
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:
+10
-3
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user