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(initialState) const abortRef = useRef(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(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 => { 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 => { 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): Promise => { 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 => { 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 } }