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 { AgentFlavor, MessageDeliveryMode } from '@hapi/protocol'
|
||||||
import type { CancelMessageResponse, SteerQueuedMessageResponse } from '@hapi/protocol/schemas'
|
import type { CancelMessageResponse, SteerQueuedMessageResponse } from '@hapi/protocol/schemas'
|
||||||
import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice'
|
import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice'
|
||||||
|
import { readBlobWithProgress, type BlobProgressReporter } from '@/lib/readBlobWithProgress'
|
||||||
|
|
||||||
export type RetryIndeterminateMessageResponse =
|
export type RetryIndeterminateMessageResponse =
|
||||||
| { status: 'retried' | 'already-queued' | 'retry-unavailable'; localId: string | null }
|
| { 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}` : ''}`)
|
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 headers = new Headers()
|
||||||
const liveToken = this.getToken ? this.getToken() : null
|
const liveToken = this.getToken ? this.getToken() : null
|
||||||
const authToken = overrideToken !== undefined
|
const authToken = overrideToken !== undefined
|
||||||
@@ -739,13 +746,13 @@ export class ApiClient {
|
|||||||
const refreshed = await this.onUnauthorized()
|
const refreshed = await this.onUnauthorized()
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
this.token = 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) {
|
if (!res.ok) {
|
||||||
throw new ApiError(`HTTP ${res.status}`, res.status, undefined, await res.text().catch(() => undefined))
|
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> {
|
async readSessionFile(sessionId: string, path: string): Promise<FileReadResponse> {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe('GeneratedImageCard video fetch', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Load video' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Load video' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
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 () => {
|
it('returns an evicted video card to its Load button to bound memory', async () => {
|
||||||
renderVideoCards(['v1', 'v2', 'v3', 'v4'])
|
renderVideoCards(['v1', 'v2', 'v3', 'v4'])
|
||||||
const buttons = screen.getAllByRole('button', { name: 'Load video' })
|
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 [error, setError] = useState<string | null>(null)
|
||||||
const [imageStyle, setImageStyle] = useState<CSSProperties | undefined>(undefined)
|
const [imageStyle, setImageStyle] = useState<CSSProperties | undefined>(undefined)
|
||||||
const [loadMedia, setLoadMedia] = useState(false)
|
const [loadMedia, setLoadMedia] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<{ loaded: number; total: number | null } | null>(null)
|
||||||
const objectUrlRef = useRef<string | null>(null)
|
const objectUrlRef = useRef<string | null>(null)
|
||||||
const evictedRef = useRef(false)
|
const evictedRef = useRef(false)
|
||||||
const isVideo = isInlineVideoMimeType(props.block.mimeType)
|
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.
|
// listener brings this card back to its Load button when evicted.
|
||||||
setError(null)
|
setError(null)
|
||||||
evictedRef.current = false
|
evictedRef.current = false
|
||||||
|
setProgress({ loaded: 0, total: null })
|
||||||
const unsubscribe = subscribeGeneratedMediaEviction(mediaKey, () => {
|
const unsubscribe = subscribeGeneratedMediaEviction(mediaKey, () => {
|
||||||
evictedRef.current = true
|
evictedRef.current = true
|
||||||
setObjectUrl(null)
|
setObjectUrl(null)
|
||||||
setLoadMedia(false)
|
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) => {
|
.then((url) => {
|
||||||
if (!disposed && !evictedRef.current) {
|
if (!disposed && !evictedRef.current) {
|
||||||
setObjectUrl(url)
|
setObjectUrl(url)
|
||||||
}
|
}
|
||||||
|
if (!disposed) {
|
||||||
|
setProgress(null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
|
setProgress(null)
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load inline media')
|
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'}
|
{isVideo ? 'Load video' : isAudio ? 'Load audio' : 'Prepare download'}
|
||||||
</button>
|
</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>
|
</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