/* ========================================================================== AI PLAYGROUND — API CLIENT One place that knows how to call the backend, how long to wait, what to do when it fails, and how to describe the failure to a human. Every lab uses `AIP.run(...)`, which wires a request to the loading, error, timeout, empty and retry states of a stage element so no lab reimplements them. ========================================================================== */ (function () { 'use strict'; const AIP = (window.AIP = window.AIP || {}); const { escapeHTML, formatMs } = AIP; /* ====================================================================== TIMEOUTS Generous where a hosted model has to cold-start, tight where the work is local. ====================================================================== */ const TIMEOUTS = { '/api/detect': 45000, '/api/depth': 60000, '/api/enhance': 30000, '/api/chat': 90000, '/api/dream': 180000, '/api/vlm': 90000, '/api/audio/tts_sync': 60000, '/api/audio/stt': 90000, '/api/health': 8000, default: 60000, }; /* ====================================================================== HUMAN-READABLE FAILURES Keyed on the `code` the backend sends, with a network/timeout fallback. ====================================================================== */ const MESSAGES = { rate_limited: { title: 'Slow down a moment', text: 'You have made a lot of requests in the last minute. Wait about a minute and try again.', retry: true, }, model_offline: { title: 'This model is not loaded', text: 'The server could not start this model. Other labs are unaffected — try one that runs locally.', retry: false, }, no_token: { title: 'Hosted model unavailable', text: 'This lab calls a hosted model, which needs an API token configured on the server. The local labs (Vision, Depth, Restore) still work.', retry: false, }, upstream_failed: { title: 'The model did not respond', text: 'Hosted models sleep when idle and take a moment to wake. A second attempt usually succeeds.', retry: true, }, inference_failed: { title: 'Processing failed', text: 'The model started but could not finish on this input. Try a different file.', retry: true, }, bad_type: { title: 'Unsupported file', text: 'That file type is not accepted here. Check the formats listed above the upload area.', retry: false, }, bad_image: { title: 'Could not read that image', text: 'The file may be corrupt or not actually an image. Try another one.', retry: false, }, too_large: { title: 'File too large', text: 'The limit is 10MB. Resize or compress the file and try again.', retry: false, }, no_input: { title: 'Nothing to process', text: 'Add an input before running the model.', retry: false, }, timeout: { title: 'Timed out', text: 'The model took longer than expected. This usually means it is cold-starting rather than broken.', retry: true, }, network: { title: 'Connection lost', text: 'The request never reached the server. Check your connection and try again.', retry: true, }, aborted: { title: 'Cancelled', text: 'The request was cancelled.', retry: true }, unknown: { title: 'Something went wrong', text: 'An unexpected error occurred. Trying again is usually worth a shot.', retry: true, }, }; class ApiError extends Error { constructor(code, message, status) { super(message || (MESSAGES[code] || MESSAGES.unknown).text); this.name = 'ApiError'; this.code = code || 'unknown'; this.status = status; const m = MESSAGES[this.code] || MESSAGES.unknown; this.title = m.title; this.retryable = m.retry; } } /* ====================================================================== REQUEST ====================================================================== */ const inflight = new Map(); async function request(path, options) { options = options || {}; const timeout = options.timeout || TIMEOUTS[path] || TIMEOUTS.default; // One request per endpoint at a time; a new one supersedes the old. if (options.supersede !== false && inflight.has(path)) { try { inflight.get(path).abort(); } catch (e) { /* noop */ } } const controller = new AbortController(); inflight.set(path, controller); if (options.signal) { options.signal.addEventListener('abort', () => controller.abort()); } const timer = setTimeout(() => { controller.timedOut = true; controller.abort(); }, timeout); const init = { method: options.method || 'POST', signal: controller.signal }; if (options.body instanceof FormData) { init.body = options.body; // browser sets the boundary } else if (options.json) { init.headers = { 'Content-Type': 'application/json' }; init.body = JSON.stringify(options.json); } if (init.method === 'GET') delete init.body; const started = performance.now(); try { const res = await fetch(path, init); clearTimeout(timer); inflight.delete(path); let data = null; const ct = res.headers.get('content-type') || ''; if (ct.includes('application/json')) { data = await res.json().catch(() => null); } if (!res.ok) { const code = (data && data.code) || (res.status === 429 ? 'rate_limited' : res.status === 413 ? 'too_large' : res.status === 503 ? 'model_offline' : res.status === 502 ? 'upstream_failed' : 'unknown'); throw new ApiError(code, data && data.error, res.status); } if (!data) throw new ApiError('unknown', 'The server returned an unreadable response.', res.status); // Round-trip time, so labs can show real numbers rather than guesses. data.client_ms = Math.round(performance.now() - started); return data; } catch (err) { clearTimeout(timer); inflight.delete(path); if (err instanceof ApiError) throw err; if (err.name === 'AbortError') { throw new ApiError(controller.timedOut ? 'timeout' : 'aborted'); } throw new ApiError('network', err.message); } } function cancel(path) { if (inflight.has(path)) { try { inflight.get(path).abort(); } catch (e) { /* noop */ } inflight.delete(path); } } /* ====================================================================== HEALTH Fetched once and cached, so a lab whose model is offline can say so before the user uploads anything. ====================================================================== */ let healthPromise = null; function health() { if (!healthPromise) { healthPromise = request('/api/health', { method: 'GET', supersede: false }) .catch(() => ({})); // treat an unreachable health check as "unknown" } return healthPromise; } /* ====================================================================== STATE RENDERING ====================================================================== */ const ICONS = { error: '', empty: '', offline: '', }; const state = { /** Loading, with an optional ordered list of pipeline steps that advance * on a timer so the user can see what stage the work is at. */ loading(el, opts) { opts = opts || {}; const steps = opts.steps || []; el.innerHTML = '
' + escapeHTML(opts.text || 'Running the model…') + '
' + (opts.cancel ? '' : '') + '' + escapeHTML(error.title || 'Something went wrong') + '
' + '' + escapeHTML(error.message) + '
' + (error.retryable && onRetry ? '' : '') + '' + escapeHTML(title || 'Nothing here yet') + '
' + '' + escapeHTML(text || '') + '
' + '