import type { CacheStats, GenerateResponse, GenerateSettings, HistoryEntry, ListResponse, ListCategory, ListCategoryResponse, PreflightResponse, SseEvent, BackendRunStartResponse, BackendRunDetail, PendingClientQueueItem, SavedThumbnailTemplate, YoutubeVideosResponse, } from './types' // Base URL for the Flask backend. Starts from the build-time env var, but can // be overridden at runtime from the Settings page via `setBackendBaseUrl()` — // that way users can point the UI at a different host without a rebuild. const DEFAULT_API_BASE: string = (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? '' let API_BASE: string = DEFAULT_API_BASE export function setBackendBaseUrl(url: string): void { API_BASE = (url ?? '').trim() || DEFAULT_API_BASE } export function getBackendBaseUrl(): string { return API_BASE } function buildUrl(path: string): string { if (!API_BASE) return path return `${API_BASE.replace(/\/$/, '')}${path}` } async function parseJson(res: Response): Promise { const text = await res.text() if (!text) return {} as T try { return JSON.parse(text) as T } catch { throw new Error(`Invalid JSON response (${res.status}): ${text.slice(0, 200)}`) } } async function postJson(path: string, body: unknown): Promise { const res = await fetch(buildUrl(path), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const text = await res.text() if (!res.ok && res.status === 409) { const rejected = tryParseRejection(text) if (rejected) throw rejected } if (!text) { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return {} as T } let data: T & { error?: string } try { data = JSON.parse(text) as T & { error?: string } } catch { throw new Error(`Invalid JSON response (${res.status}): ${text.slice(0, 200)}`) } if (!res.ok || data.error) { throw new Error(data.error || `${res.status} ${res.statusText}`) } return data } async function getJson(path: string): Promise { const res = await fetch(buildUrl(path)) const data = await parseJson(res) if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText) return data } // ─── Preflight cache ─────────────────────────────────────────────────────── // User complaint: "preflight being hit every 2-3s from the UI". Nothing in // the codebase polls that fast but components fetch preflight on mount, // so a tab-happy user can easily trigger 5+ calls in a second. Cache the // response for 30s and coalesce concurrent lookups to a single request. const PREFLIGHT_CACHE_MS = 30_000 interface PreflightCache { value: PreflightResponse | null fetchedAt: number inFlight: Promise | null } let _preflightCache: PreflightCache = { value: null, fetchedAt: 0, inFlight: null } export function invalidatePreflightCache(): void { _preflightCache = { value: null, fetchedAt: 0, inFlight: null } } export const api = { generate: (text: string, settings: GenerateSettings = {}) => postJson('/generate', { text, ...settings }), startTextToVideoRun: (text: string, settings: GenerateSettings = {}) => postJson('/runs/text-to-video', { text, ...settings }), startHtmlToVideoRun: (html: string, settings: GenerateSettings = {}) => postJson('/runs/html-to-video', { html, ...settings }), /** * Submit pre-captured screenshots to the same MP4/PPTX export pipeline * used by Text → Video. The screenshots are uploaded as * ``screenshots[]`` parts; ``settings`` (resolution, fps, project info, * thumbnails, …) ride along as form fields so the backend can apply * the canonical ``class_X_subject_chapter_Y_exercise_`` filename * scheme used everywhere else. */ startScreenshotsToVideoRun: async ( screenshots: File[], settings: GenerateSettings = {}, ): Promise => { const fd = new FormData() for (const f of screenshots) fd.append('screenshots[]', f, f.name) for (const [key, value] of Object.entries(settings)) { if (value === undefined || value === null) continue if (typeof value === 'boolean') fd.append(key, value ? '1' : '0') else fd.append(key, String(value)) } const res = await fetch(buildUrl('/runs/screenshots-to-video'), { method: 'POST', body: fd, }) const text = await res.text() if (!res.ok && res.status === 409) { const rejected = tryParseRejection(text) if (rejected) throw rejected } if (!text) { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return {} as BackendRunStartResponse } let data: BackendRunStartResponse & { error?: string } try { data = JSON.parse(text) as BackendRunStartResponse & { error?: string } } catch { throw new Error(`Invalid JSON response (${res.status}): ${text.slice(0, 200)}`) } if (!res.ok || data.error) { throw new Error(data.error || `${res.status} ${res.statusText}`) } return data }, getRun: (runId: string) => getJson(`/runs/${encodeURIComponent(runId)}`), /** * Subscribe to a server-sent event stream for a run. Used by the * Processes page to react to status changes immediately instead of * polling /runs/. The promise resolves when the run reaches a * terminal state (or the consumer aborts via signal). */ streamRunEvents: (runId: string, opts: Pick) => streamSseGet(`/runs/${encodeURIComponent(runId)}/events`, opts), getPendingClientQueue: () => getJson<{ success: boolean; items: PendingClientQueueItem[] }>('/runs/pending-client-queue'), savePendingClientQueue: async (items: PendingClientQueueItem[]) => { const res = await fetch(buildUrl('/runs/pending-client-queue'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }) const data = await parseJson<{ success?: boolean; items?: PendingClientQueueItem[]; error?: string }>(res) if (!res.ok || data.error) throw new Error(data.error || res.statusText) return data }, generateHtml: (html: string, settings: GenerateSettings = {}) => postJson('/generate-html', { html, ...settings }), cancel: (operationId: string) => postJson<{ success: boolean; message: string }>(`/cancel/${encodeURIComponent(operationId)}`, {}), cancelRun: ( runId: string, options?: { mode?: 'now' | 'after_html' | 'after_screenshots' | 'after_pptx' | 'after_video' delete_outputs?: boolean }, ) => postJson<{ success: boolean; message: string }>(`/runs/${encodeURIComponent(runId)}/cancel`, options ?? {}), beautify: (html: string) => postJson<{ html: string; validation: unknown }>('/beautify', { html }), minify: (html: string) => postJson<{ html: string; original_size: number; minified_size: number; reduction_percent: number }>( '/minify', { html }, ), regenerate: (htmlFilename: string, settings: GenerateSettings = {}) => postJson('/regenerate', { html_filename: htmlFilename, ...settings }), list: () => getJson('/list'), /** * Per-category, paginated, mtime-sorted listing. Use this when the * Library tab only needs one category — avoids the full /list scan. */ listCategory: ( category: ListCategory, opts: { page?: number; size?: number; since?: number } = {}, ) => { const params = new URLSearchParams() if (opts.page) params.set('page', String(opts.page)) if (opts.size) params.set('size', String(opts.size)) if (opts.since) params.set('since', String(opts.since)) const qs = params.toString() return getJson(`/list/${category}${qs ? `?${qs}` : ''}`) }, history: () => getJson('/history'), youtubeVideos: () => getJson('/youtube/videos'), clearHistory: () => postJson<{ success: boolean; message: string }>('/history/clear', {}), deleteFile: async (type: 'screenshot' | 'html' | 'presentation' | 'video', filename: string) => { // Encode each path segment separately. encodeURIComponent would escape // `/` as `%2F`, which Werkzeug's dev server does NOT decode back to `/` // in PATH_INFO — so the converter would get a literal // `%2F` and fail to match any file on disk. Screenshots inside batch // subfolders (e.g. `batch 3/5(1).png`) rely on the split-and-join // treatment. const encoded = filename.split('/').map(encodeURIComponent).join('/') const res = await fetch(buildUrl(`/delete/${type}/${encoded}`), { method: 'DELETE', }) return parseJson<{ success?: boolean; error?: string }>(res) }, /** * Preflight probes are expensive on Windows (they spawn POWERPNT.EXE to * verify COM availability). A single client cache with a 30s TTL stops * us from hammering the backend every time a component mounts. * * Pass `{ fresh: true }` to bypass the cache — the "Refresh" button on * the Home preflight tile uses this, and the Settings page's "Ping" * button does too. */ preflight: (opts?: { fresh?: boolean }): Promise => { // Cached path — reuse a recent response without hitting the network. if (!opts?.fresh) { const now = Date.now() if ( _preflightCache.value && now - _preflightCache.fetchedAt < PREFLIGHT_CACHE_MS ) { return Promise.resolve(_preflightCache.value) } } // Always coalesce concurrent callers onto the same in-flight request, // even when one of them passed `fresh: true`. A fresh probe is still // a newer-than-cache result, so a cached waiter is perfectly happy // with it — we just had to stop the previous "fresh bypasses the // in-flight check" branch, which could issue 4 parallel // POWERPNT-spawning probes if four components mounted together asked // for fresh data at once. if (_preflightCache.inFlight) return _preflightCache.inFlight const p = getJson(opts?.fresh ? '/preflight?fresh=1' : '/preflight') .then((r) => { _preflightCache = { value: r, fetchedAt: Date.now(), inFlight: null } return r }) .catch((e) => { _preflightCache = { ..._preflightCache, inFlight: null } throw e }) _preflightCache = { ..._preflightCache, inFlight: p } return p }, cacheStats: () => getJson('/cache/stats'), clearCache: () => postJson<{ success: boolean; message: string }>('/cache/clear', {}), /** * Backend version metadata — git SHA + boot timestamp. Cached for the * page's lifetime (the value can't change without a backend restart, at * which point the SSE/polling layer reconnects anyway). */ version: () => getJson<{ service: string; sha: string; started_at: string }>('/version'), screenshotUrl: (filename: string) => buildUrl(`/screenshots/${filename.split('/').map(encodeURIComponent).join('/')}`), htmlUrl: (filename: string) => buildUrl(`/html/${encodeURIComponent(filename)}`), downloadUrl: (filepath: string) => buildUrl(`/download/${filepath.split(/[\\/]/).map(encodeURIComponent).join('/')}`), thumbnailUrl: (filename: string) => buildUrl(`/thumbnails/${encodeURIComponent(filename)}`), listThumbnailTemplates: (className?: string, subject?: string) => { const params = new URLSearchParams() if (className) params.set('className', className) if (subject) params.set('subject', subject) const qs = params.toString() return getJson<{ success: boolean; templates: SavedThumbnailTemplate[] }>( `/thumbnail-templates${qs ? `?${qs}` : ''}`, ) }, saveThumbnailTemplate: (template: Omit) => postJson<{ success: boolean; template: SavedThumbnailTemplate }>('/thumbnail-templates', template), deleteThumbnailTemplate: (id: string) => fetch(buildUrl(`/thumbnail-templates/${encodeURIComponent(id)}`), { method: 'DELETE' }) .then((res) => parseJson<{ success?: boolean; error?: string }>(res)), /** POST a JSON template payload and get back the matching PNG. * * Used as a backend parity check (and as a server-rendered fallback) for * the same `ThumbnailTemplateState` produced by the frontend editor. */ renderThumbnailTemplate: async ( payload: Record, ): Promise => { const r = await fetch(buildUrl('/render-thumbnail-template'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!r.ok) { const msg = await r.text() throw new Error(`Render failed (${r.status}): ${msg}`) } return r.blob() }, uploadThumbnail: async ( file: File, ): Promise<{ success: boolean; filename: string; url: string; size_bytes: number }> => { const fd = new FormData() fd.append('file', file) const r = await fetch(buildUrl('/upload-thumbnail'), { method: 'POST', body: fd }) if (!r.ok) { const msg = await r.text() throw new Error(`Upload failed (${r.status}): ${msg}`) } return r.json() }, downloadZip: async (files: string[], name = 'screenshots'): Promise => { const res = await fetch(buildUrl('/download-zip'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files, name }), }) if (!res.ok) throw new Error(`Failed to download ZIP: ${res.status}`) return res.blob() }, } /** * Stream Server-Sent Events from a POST endpoint. Flask's `/generate-sse` and * `/image-to-screenshots-sse` accept a POST body and stream `data: {...}\n\n` * lines — `EventSource` only supports GET, so we drive this manually with fetch * + ReadableStream. */ export interface SseStreamOptions { body: BodyInit headers?: Record signal?: AbortSignal onEvent: (ev: SseEvent) => void } /** * Thrown when the backend rejects a run because another one is in flight or * the same payload was just submitted within the dedup window. The 409 body * shape comes from `src/utils/run_guard.py::RunRejected`. */ export class RunRejectedError extends Error { reason: 'in_flight' | 'duplicate' | 'unknown' operationId: string | null constructor(reason: 'in_flight' | 'duplicate' | 'unknown', message: string, operationId: string | null = null) { super(message) this.name = 'RunRejectedError' this.reason = reason this.operationId = operationId } } function tryParseRejection(text: string): RunRejectedError | null { try { const data = JSON.parse(text) as { reason?: string error?: string message?: string operation_id?: string } const reasonRaw = (data.reason ?? '').toLowerCase() const reason: RunRejectedError['reason'] = reasonRaw === 'in_flight' || reasonRaw === 'duplicate' ? reasonRaw : 'unknown' const message = data.error || data.message || 'Run rejected by backend' return new RunRejectedError(reason, message, data.operation_id ?? null) } catch { return null } } export async function streamSse(path: string, opts: SseStreamOptions): Promise { const res = await fetch(buildUrl(path), { method: 'POST', headers: { Accept: 'text/event-stream', ...(opts.headers ?? {}) }, body: opts.body, signal: opts.signal, }) if (!res.ok || !res.body) { const text = !res.ok ? await res.text().catch(() => '') : '' if (res.status === 409) { const rejected = tryParseRejection(text) if (rejected) throw rejected } throw new Error(`SSE request failed: ${res.status} ${res.statusText} ${text.slice(0, 200)}`) } const reader = res.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // SSE messages are separated by blank lines. const parts = buffer.split(/\r?\n\r?\n/) buffer = parts.pop() ?? '' for (const part of parts) { const dataLines = part .split(/\r?\n/) .filter((l) => l.startsWith('data:')) .map((l) => l.slice(5).trimStart()) if (dataLines.length === 0) continue const raw = dataLines.join('\n') try { const parsed = JSON.parse(raw) as SseEvent opts.onEvent(parsed) } catch { // Ignore non-JSON keepalives / comments. } } } } export async function streamSseGet( path: string, opts: Pick, ): Promise { const res = await fetch(buildUrl(path), { method: 'GET', headers: { Accept: 'text/event-stream' }, signal: opts.signal, }) if (!res.ok || !res.body) { const text = !res.ok ? await res.text().catch(() => '') : '' throw new Error(`SSE request failed: ${res.status} ${res.statusText} ${text.slice(0, 200)}`) } const reader = res.body.getReader() const decoder = new TextDecoder('utf-8') let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split(/\r?\n\r?\n/) buffer = parts.pop() ?? '' for (const part of parts) { const dataLines = part .split(/\r?\n/) .filter((l) => l.startsWith('data:')) .map((l) => l.slice(5).trimStart()) if (dataLines.length === 0) continue const raw = dataLines.join('\n') try { opts.onEvent(JSON.parse(raw) as SseEvent) } catch { // Ignore non-JSON keepalives / comments. } } } }