Spaces:
Running
Running
| import { useCallback, useRef, useState } from 'react' | |
| import { api, RunRejectedError, streamSse, streamSseGet } from '../api/client' | |
| import type { BackendRunDetail, GenerateSettings, SseEvent } from '../api/types' | |
| /** | |
| * Convert any thrown value into a user-friendly error string. The 409 | |
| * `RunRejectedError` is the most common non-trivial case — surface its | |
| * `reason` so wizards can render "Another run is in progress…" / | |
| * "Same payload was just submitted…" instead of a raw stack. | |
| */ | |
| function friendlyErrorMessage(err: unknown): string { | |
| if (err instanceof RunRejectedError) { | |
| if (err.reason === 'in_flight') { | |
| return 'Another run is in progress on this backend. Wait for it to finish (or cancel it from Processes), then try again.' | |
| } | |
| if (err.reason === 'duplicate') { | |
| return 'You just submitted the same content seconds ago. Wait a moment, change something, or open the existing run from Processes.' | |
| } | |
| return err.message | |
| } | |
| if (err instanceof Error) return err.message | |
| return String(err) | |
| } | |
| function rejectionReason(err: unknown): GenerationState['rejectedReason'] | undefined { | |
| return err instanceof RunRejectedError ? err.reason : undefined | |
| } | |
| export interface GenerationResult { | |
| html_filename?: string | |
| html_content?: string | |
| screenshot_files: string[] | |
| screenshot_folder?: string | |
| presentation_file?: string | |
| video_file?: string | |
| operation_id?: string | |
| } | |
| export interface GenerationState { | |
| status: 'idle' | 'running' | 'success' | 'error' | 'cancelled' | |
| stage?: string | |
| message?: string | |
| progress: number | |
| etaSeconds?: number | |
| error?: string | |
| /** | |
| * Set when the most recent error came from a backend 409 rejection. | |
| * Wizards can use this to render an "Open Processes" call-to-action | |
| * instead of a generic error toast. | |
| */ | |
| rejectedReason?: 'in_flight' | 'duplicate' | 'unknown' | |
| result?: GenerationResult | |
| operationId?: string | |
| } | |
| const initialState: GenerationState = { status: 'idle', progress: 0 } | |
| function resultFromRun(run: BackendRunDetail['run'], fallbackOperationId: string): GenerationResult { | |
| const outputs = run.outputs ?? {} | |
| return { | |
| html_filename: outputs.html_filename ?? outputs.html_file, | |
| screenshot_files: outputs.screenshot_files ?? [], | |
| screenshot_folder: outputs.screenshot_folder, | |
| presentation_file: outputs.presentation_file ?? outputs.presentation_path, | |
| video_file: outputs.video_file ?? outputs.video_path, | |
| operation_id: run.operation_id ?? fallbackOperationId, | |
| } | |
| } | |
| function etaFromRun(run: BackendRunDetail['run']): number | undefined { | |
| const raw = | |
| run.settings?.estimated_total_seconds ?? | |
| run.metrics?.estimated_total_seconds ?? | |
| run.metrics?.eta_seconds | |
| const value = typeof raw === 'number' ? raw : Number(raw) | |
| return Number.isFinite(value) && value > 0 ? value : undefined | |
| } | |
| export function useGenerate() { | |
| const [state, setState] = useState<GenerationState>(initialState) | |
| const abortRef = useRef<AbortController | null>(null) | |
| // Track the live operation id in a ref so cancel() can read it without | |
| // closing over potentially-stale state. The previous version captured | |
| // state.operationId via useCallback deps, which meant a Cancel click in | |
| // the same render that emitted `started` would still cancel `undefined`. | |
| const opIdRef = useRef<string | null>(null) | |
| // Set to true by `cancel()` and cleared whenever a fresh run starts. | |
| // Used so the SSE catch-blocks can distinguish between "user requested | |
| // cancel → AbortError on fetch" (terminal, surface as cancelled) and | |
| // "generic AbortError because something else tore down the stream" | |
| // (don't touch state). Without this flag the catch-block silently | |
| // swallowed the AbortError and the UI stayed on "running" forever | |
| // even though the backend had actually accepted the cancel request. | |
| const cancelRequestedRef = useRef(false) | |
| const reset = useCallback(() => { | |
| cancelRequestedRef.current = false | |
| abortRef.current?.abort() | |
| abortRef.current = null | |
| opIdRef.current = null | |
| setState(initialState) | |
| }, []) | |
| const runSseJson = useCallback( | |
| async ( | |
| path: '/generate-sse', | |
| payload: unknown, | |
| ): Promise<GenerationResult | null> => { | |
| abortRef.current?.abort() | |
| cancelRequestedRef.current = false | |
| const ctl = new AbortController() | |
| abortRef.current = ctl | |
| setState({ status: 'running', progress: 0, message: 'Starting…', stage: 'init' }) | |
| let opId: string | undefined | |
| opIdRef.current = null | |
| const result: GenerationResult = { screenshot_files: [] } | |
| const handle = (ev: SseEvent) => { | |
| if (ev.type === 'started') { | |
| opId = ev.operation_id | |
| opIdRef.current = ev.operation_id ?? null | |
| setState((s) => ({ | |
| ...s, | |
| operationId: ev.operation_id, | |
| etaSeconds: ev.estimated_total_seconds, | |
| progress: ev.progress ?? s.progress, | |
| })) | |
| } else if (ev.type === 'progress') { | |
| setState((s) => ({ | |
| ...s, | |
| stage: ev.stage, | |
| message: ev.message, | |
| progress: ev.progress, | |
| etaSeconds: ev.eta_seconds ?? s.etaSeconds, | |
| })) | |
| } else if (ev.type === 'html_generated') { | |
| result.html_filename = ev.html_filename | |
| result.html_content = ev.html_content | |
| } else if (ev.type === 'screenshot') { | |
| result.screenshot_files.push(ev.filename) | |
| setState((s) => ({ | |
| ...s, | |
| stage: 'screenshot', | |
| message: `Captured ${ev.index}/${ev.total}`, | |
| progress: ev.progress, | |
| })) | |
| } else if (ev.type === 'complete') { | |
| result.html_filename = ev.html_filename ?? result.html_filename | |
| result.html_content = ev.html_content ?? result.html_content | |
| result.screenshot_files = ev.screenshot_files ?? result.screenshot_files | |
| result.screenshot_folder = ev.screenshot_folder | |
| result.presentation_file = ev.presentation_file | |
| result.video_file = ev.video_file | |
| result.operation_id = ev.operation_id ?? opId | |
| setState({ | |
| status: 'success', | |
| progress: 100, | |
| stage: 'complete', | |
| message: ev.message ?? `Generated ${result.screenshot_files.length} screenshot(s)`, | |
| result, | |
| operationId: result.operation_id, | |
| }) | |
| } else if (ev.type === 'error') { | |
| setState({ status: 'error', progress: 100, error: ev.message }) | |
| } else if (ev.type === 'cancelled') { | |
| setState({ status: 'cancelled', progress: 100, message: ev.message }) | |
| } | |
| } | |
| try { | |
| await streamSse(path, { | |
| body: JSON.stringify(payload), | |
| headers: { 'Content-Type': 'application/json' }, | |
| signal: ctl.signal, | |
| onEvent: handle, | |
| }) | |
| } catch (err) { | |
| if ((err as { name?: string }).name === 'AbortError') { | |
| if (cancelRequestedRef.current) { | |
| setState((s) => ({ | |
| ...s, | |
| status: 'cancelled', | |
| progress: 100, | |
| stage: 'cancelled', | |
| message: s.message ?? 'Cancelled', | |
| })) | |
| } | |
| } else { | |
| setState((s) => ({ | |
| ...s, | |
| status: 'error', | |
| error: friendlyErrorMessage(err), | |
| rejectedReason: rejectionReason(err), | |
| })) | |
| } | |
| return null | |
| } | |
| return result.screenshot_files.length > 0 ? result : null | |
| }, | |
| [], | |
| ) | |
| void runSseJson | |
| const runSseForm = useCallback( | |
| async (path: '/image-to-screenshots-sse', formData: FormData): Promise<GenerationResult | null> => { | |
| abortRef.current?.abort() | |
| cancelRequestedRef.current = false | |
| const ctl = new AbortController() | |
| abortRef.current = ctl | |
| setState({ status: 'running', progress: 0, message: 'Starting…', stage: 'init' }) | |
| let opId: string | undefined | |
| opIdRef.current = null | |
| const result: GenerationResult = { screenshot_files: [] } | |
| const handle = (ev: SseEvent) => { | |
| if (ev.type === 'started') { | |
| opId = ev.operation_id | |
| opIdRef.current = ev.operation_id ?? null | |
| setState((s) => ({ ...s, operationId: ev.operation_id, progress: ev.progress ?? s.progress })) | |
| } else if (ev.type === 'progress') { | |
| setState((s) => ({ ...s, stage: ev.stage, message: ev.message, progress: ev.progress })) | |
| } else if (ev.type === 'html_generated') { | |
| result.html_filename = ev.html_filename | |
| result.html_content = ev.html_content | |
| } else if (ev.type === 'screenshot') { | |
| result.screenshot_files.push(ev.filename) | |
| setState((s) => ({ | |
| ...s, | |
| stage: 'screenshot', | |
| message: `Captured ${ev.index}/${ev.total}`, | |
| progress: ev.progress, | |
| })) | |
| } else if (ev.type === 'complete') { | |
| result.html_filename = ev.html_filename ?? result.html_filename | |
| result.screenshot_files = ev.screenshot_files ?? result.screenshot_files | |
| result.screenshot_folder = ev.screenshot_folder | |
| result.presentation_file = ev.presentation_file | |
| result.video_file = ev.video_file | |
| result.operation_id = ev.operation_id ?? opId | |
| setState({ | |
| status: 'success', | |
| progress: 100, | |
| stage: 'complete', | |
| message: ev.message ?? `Generated ${result.screenshot_files.length} screenshot(s)`, | |
| result, | |
| operationId: result.operation_id, | |
| }) | |
| } else if (ev.type === 'error') { | |
| setState({ status: 'error', progress: 100, error: ev.message }) | |
| } else if (ev.type === 'cancelled') { | |
| setState({ status: 'cancelled', progress: 100, message: ev.message }) | |
| } | |
| } | |
| try { | |
| await streamSse(path, { | |
| body: formData, | |
| signal: ctl.signal, | |
| onEvent: handle, | |
| }) | |
| } catch (err) { | |
| if ((err as { name?: string }).name === 'AbortError') { | |
| if (cancelRequestedRef.current) { | |
| setState((s) => ({ | |
| ...s, | |
| status: 'cancelled', | |
| progress: 100, | |
| stage: 'cancelled', | |
| message: s.message ?? 'Cancelled', | |
| })) | |
| } | |
| } else { | |
| setState((s) => ({ | |
| ...s, | |
| status: 'error', | |
| error: friendlyErrorMessage(err), | |
| rejectedReason: rejectionReason(err), | |
| })) | |
| } | |
| return null | |
| } | |
| return result.screenshot_files.length > 0 ? result : null | |
| }, | |
| [], | |
| ) | |
| const runBackendQueuedRun = useCallback( | |
| async (startRun: () => ReturnType<typeof api.startTextToVideoRun>): Promise<GenerationResult | null> => { | |
| abortRef.current?.abort() | |
| cancelRequestedRef.current = false | |
| const ctl = new AbortController() | |
| abortRef.current = ctl | |
| setState({ status: 'running', progress: 0, message: 'Creating backend run...', stage: 'queued' }) | |
| let started | |
| try { | |
| started = await startRun() | |
| } catch (err) { | |
| setState({ | |
| status: 'error', | |
| progress: 100, | |
| error: friendlyErrorMessage(err), | |
| rejectedReason: rejectionReason(err), | |
| }) | |
| return null | |
| } | |
| const runId = started.run_id | |
| const opId = started.operation_id | |
| opIdRef.current = opId | |
| const result: GenerationResult = { screenshot_files: [], operation_id: opId } | |
| const firstPosition = started.queue_position ?? 1 | |
| setState({ | |
| status: 'running', | |
| progress: 0, | |
| stage: firstPosition > 1 ? 'queued' : 'running', | |
| message: firstPosition > 1 ? `Queued at position ${firstPosition}` : 'Starting backend process...', | |
| operationId: opId, | |
| etaSeconds: started.estimated_total_seconds, | |
| }) | |
| let terminal = false | |
| const applyRunSnapshot = (run: BackendRunDetail['run']) => { | |
| if (terminal) return | |
| const backendStatus = String(run.status ?? '') | |
| const progress = typeof run.progress === 'number' ? run.progress : undefined | |
| const queuePosition = run.queue_position | |
| if (backendStatus === 'completed') { | |
| terminal = true | |
| const completed = resultFromRun(run, opId) | |
| Object.assign(result, completed) | |
| setState({ | |
| status: 'success', | |
| progress: 100, | |
| stage: 'complete', | |
| message: run.message ?? `Generated ${completed.screenshot_files.length} screenshot(s)`, | |
| result: completed, | |
| operationId: completed.operation_id, | |
| etaSeconds: etaFromRun(run), | |
| }) | |
| return | |
| } | |
| if (backendStatus === 'failed') { | |
| terminal = true | |
| setState({ status: 'error', progress: 100, error: run.message ?? 'Process failed', operationId: opId }) | |
| return | |
| } | |
| if (backendStatus === 'cancelled') { | |
| terminal = true | |
| setState({ status: 'cancelled', progress: 100, message: run.message ?? 'Cancelled', operationId: opId }) | |
| return | |
| } | |
| setState((s) => ({ | |
| ...s, | |
| stage: | |
| backendStatus === 'queued' && (!queuePosition || queuePosition <= 1) | |
| ? 'running' | |
| : run.stage ?? s.stage, | |
| message: | |
| backendStatus === 'queued' && (!queuePosition || queuePosition <= 1) | |
| ? 'Starting backend process...' | |
| : run.message ?? s.message, | |
| progress: progress ?? s.progress, | |
| operationId: run.operation_id ?? s.operationId, | |
| etaSeconds: etaFromRun(run) ?? s.etaSeconds, | |
| })) | |
| } | |
| const handle = (ev: SseEvent) => { | |
| if (terminal) return | |
| if (ev.type === 'queued') { | |
| const position = ev.queue_position ?? 1 | |
| setState((s) => ({ | |
| ...s, | |
| stage: position > 1 ? 'queued' : 'running', | |
| message: position > 1 ? ev.message : 'Starting backend process...', | |
| progress: ev.progress ?? s.progress, | |
| operationId: ev.operation_id ?? s.operationId, | |
| })) | |
| } else if (ev.type === 'started') { | |
| opIdRef.current = ev.operation_id ?? opId | |
| setState((s) => ({ | |
| ...s, | |
| operationId: ev.operation_id ?? opId, | |
| stage: ev.stage ?? 'running', | |
| message: ev.message ?? 'Process started', | |
| etaSeconds: ev.estimated_total_seconds ?? s.etaSeconds, | |
| progress: ev.progress ?? s.progress, | |
| })) | |
| } else if (ev.type === 'progress') { | |
| setState((s) => ({ | |
| ...s, | |
| stage: ev.stage, | |
| message: ev.message, | |
| progress: ev.progress, | |
| operationId: ev.operation_id ?? s.operationId, | |
| etaSeconds: ev.eta_seconds ?? s.etaSeconds, | |
| })) | |
| } else if (ev.type === 'complete') { | |
| const data = ev.data ?? {} | |
| result.html_filename = ev.html_filename ?? data.html_filename ?? data.html_file ?? result.html_filename | |
| result.html_content = ev.html_content ?? result.html_content | |
| result.screenshot_files = ev.screenshot_files ?? data.screenshot_files ?? result.screenshot_files | |
| result.screenshot_folder = ev.screenshot_folder ?? data.screenshot_folder | |
| result.presentation_file = ev.presentation_file ?? data.presentation_file ?? data.presentation_path | |
| result.video_file = ev.video_file ?? data.video_file ?? data.video_path | |
| result.operation_id = ev.operation_id ?? opId | |
| terminal = true | |
| setState({ | |
| status: 'success', | |
| progress: 100, | |
| stage: 'complete', | |
| message: ev.message ?? data.message ?? `Generated ${result.screenshot_files.length} screenshot(s)`, | |
| result, | |
| operationId: result.operation_id, | |
| }) | |
| } else if (ev.type === 'error') { | |
| terminal = true | |
| setState({ status: 'error', progress: 100, error: ev.message, operationId: opId }) | |
| } else if (ev.type === 'cancelled') { | |
| terminal = true | |
| setState({ status: 'cancelled', progress: 100, message: ev.message, operationId: opId }) | |
| } | |
| } | |
| const pollRun = async () => { | |
| while (!ctl.signal.aborted && !terminal) { | |
| await new Promise((resolve) => window.setTimeout(resolve, 1500)) | |
| if (ctl.signal.aborted || terminal) break | |
| try { | |
| const detail = await api.getRun(runId) | |
| if (detail.run) applyRunSnapshot(detail.run) | |
| } catch { | |
| // SSE remains the primary channel; polling is only a fallback. | |
| } | |
| } | |
| } | |
| try { | |
| await Promise.race([ | |
| streamSseGet(`/runs/${encodeURIComponent(runId)}/events`, { | |
| signal: ctl.signal, | |
| onEvent: handle, | |
| }), | |
| pollRun(), | |
| ]) | |
| } catch (err) { | |
| if ((err as { name?: string }).name === 'AbortError') { | |
| // User hit Cancel → the SSE fetch raised AbortError before a | |
| // `cancelled` event could arrive. Backend still processes the | |
| // cancel request (cancelRun above), but the UI needs to move | |
| // itself off "running" — otherwise the wizard and the tracked | |
| // run row stay frozen until a page reload. | |
| if (cancelRequestedRef.current && !terminal) { | |
| terminal = true | |
| setState((s) => ({ | |
| ...s, | |
| status: 'cancelled', | |
| progress: 100, | |
| stage: 'cancelled', | |
| message: s.message ?? 'Cancelled', | |
| operationId: opId, | |
| })) | |
| } | |
| } else { | |
| setState((s) => ({ | |
| ...s, | |
| status: 'error', | |
| error: friendlyErrorMessage(err), | |
| rejectedReason: rejectionReason(err), | |
| })) | |
| } | |
| return null | |
| } | |
| return result | |
| }, | |
| [], | |
| ) | |
| const generate = useCallback( | |
| (text: string, settings: GenerateSettings) => | |
| runBackendQueuedRun(() => api.startTextToVideoRun(text, settings)), | |
| [runBackendQueuedRun], | |
| ) | |
| const generateFromHtmlLegacy = useCallback( | |
| async (html: string, settings: GenerateSettings): Promise<GenerationResult | null> => { | |
| abortRef.current?.abort() | |
| const ctl = new AbortController() | |
| abortRef.current = ctl | |
| setState({ status: 'running', progress: 20, message: 'Rendering screenshots…', stage: 'screenshot' }) | |
| try { | |
| const res = await api.generateHtml(html, settings) | |
| const result: GenerationResult = { | |
| html_filename: res.html_filename, | |
| screenshot_files: res.screenshot_files ?? [], | |
| screenshot_folder: res.screenshot_folder, | |
| presentation_file: res.presentation_file, | |
| video_file: res.video_file, | |
| } | |
| setState({ | |
| status: 'success', | |
| progress: 100, | |
| stage: 'complete', | |
| message: res.message ?? `Generated ${result.screenshot_files.length} screenshot(s)`, | |
| result, | |
| }) | |
| return result | |
| } catch (err) { | |
| setState({ | |
| status: 'error', | |
| progress: 100, | |
| error: friendlyErrorMessage(err), | |
| rejectedReason: rejectionReason(err), | |
| }) | |
| return null | |
| } | |
| }, | |
| [], | |
| ) | |
| void generateFromHtmlLegacy | |
| const generateFromHtml = useCallback( | |
| (html: string, settings: GenerateSettings) => | |
| runBackendQueuedRun(() => api.startHtmlToVideoRun(html, settings)), | |
| [runBackendQueuedRun], | |
| ) | |
| const generateFromImage = useCallback( | |
| (formData: FormData) => runSseForm('/image-to-screenshots-sse', formData), | |
| [runSseForm], | |
| ) | |
| const cancel = useCallback(async (options?: { | |
| mode?: 'now' | 'after_html' | 'after_screenshots' | 'after_pptx' | 'after_video' | |
| delete_outputs?: boolean | |
| }) => { | |
| const op = abortRef.current | |
| const opId = opIdRef.current | |
| const mode = options?.mode ?? 'now' | |
| const deleteOutputs = Boolean(options?.delete_outputs && mode === 'now') | |
| if (mode !== 'now') { | |
| setState((s) => { | |
| if (s.status !== 'running') return s | |
| return { | |
| ...s, | |
| stage: 'cancelling', | |
| message: 'Cancellation requested. Waiting for the current step to finish...', | |
| operationId: s.operationId ?? opId ?? undefined, | |
| } | |
| }) | |
| if (opId) { | |
| try { | |
| await api.cancelRun(opId, { mode }).catch(() => api.cancel(opId)) | |
| } catch { | |
| /* ignore - backend may already have finished */ | |
| } | |
| } | |
| return | |
| } | |
| // Flip the UI to "cancelled" immediately. The previous implementation | |
| // only aborted the SSE fetch and fired off the backend cancel, which | |
| // relied on a server-sent `cancelled` event (or a polling snapshot) | |
| // to flip the state — but when the abort fired first, the fetch | |
| // raised AbortError and the catch-block swallowed it silently, so | |
| // the wizard / Processes stayed stuck on "running" forever even | |
| // though the backend had already cancelled the run. | |
| setState((s) => { | |
| if (s.status !== 'running') return s | |
| return { | |
| ...s, | |
| status: 'cancelled', | |
| progress: 100, | |
| stage: 'cancelled', | |
| message: s.message ?? 'Cancelling…', | |
| operationId: s.operationId ?? opId ?? undefined, | |
| } | |
| }) | |
| cancelRequestedRef.current = true | |
| if (opId) { | |
| try { | |
| await api.cancelRun(opId, { mode: 'now', delete_outputs: deleteOutputs }).catch(() => api.cancel(opId)) | |
| } catch { | |
| /* ignore — backend may already have finished */ | |
| } | |
| } | |
| op?.abort() | |
| }, []) | |
| return { state, generate, generateFromHtml, generateFromImage, cancel, reset } | |
| } | |