/* ========================================================================== 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 = '
' + '
' + (steps.length ? '
' + steps.map((s, i) => '
' + escapeHTML(s) + '
').join('') + '
' : '') + '

' + escapeHTML(opts.text || 'Running the model…') + '

' + (opts.cancel ? '' : '') + '
'; if (opts.cancel) { const btn = el.querySelector('[data-cancel]'); if (btn) btn.addEventListener('click', opts.cancel); } // Advance the ticker; the last step stays active until the response lands. if (steps.length > 1 && !AIP.prefersReducedMotion()) { let i = 0; const interval = setInterval(() => { const nodes = el.querySelectorAll('.step'); if (!nodes.length || i >= steps.length - 1) { clearInterval(interval); return; } nodes[i].classList.remove('is-active'); nodes[i].classList.add('is-done'); i += 1; nodes[i].classList.add('is-active'); }, opts.stepMs || 900); el._stepTimer = interval; } }, error(el, error, onRetry) { if (el._stepTimer) clearInterval(el._stepTimer); const isOffline = error.code === 'model_offline' || error.code === 'no_token'; el.innerHTML = ''; const btn = el.querySelector('[data-retry]'); if (btn && onRetry) btn.addEventListener('click', onRetry); }, empty(el, title, text) { el.innerHTML = '
' + '' + ICONS.empty + '' + '

' + escapeHTML(title || 'Nothing here yet') + '

' + '

' + escapeHTML(text || '') + '

' + '
'; }, }; /* ====================================================================== RUN — the wrapper every lab uses ====================================================================== */ async function run(config) { const { path, body, json, method, stage, // element that shows loading / error steps, loadingText, // loading presentation onSuccess, // (data) => void onError, // optional override button, // button to put in a busy state cancellable, } = config; if (button) { button.classList.add('is-busy'); button.setAttribute('aria-busy', 'true'); } const retry = () => run(config); if (stage) { state.loading(stage, { steps, text: loadingText, cancel: cancellable ? () => cancel(path) : null, }); } try { const data = await request(path, { body, json, method }); if (stage && stage._stepTimer) clearInterval(stage._stepTimer); if (onSuccess) onSuccess(data); return data; } catch (error) { if (stage && stage._stepTimer) clearInterval(stage._stepTimer); if (error.code === 'aborted') return null; // user-initiated, stay quiet if (onError) onError(error); else if (stage) state.error(stage, error, error.retryable ? retry : null); else AIP.toast.error(error.title + ' — ' + error.message); return null; } finally { if (button) { button.classList.remove('is-busy'); button.removeAttribute('aria-busy'); } } } /* ====================================================================== IMAGE HELPERS ====================================================================== */ /** Downscale before upload. Keeps large phone photos under the size limit * and cuts transfer time on slow connections. */ function prepareImage(file, maxDim) { maxDim = maxDim || 1400; return new Promise((resolve, reject) => { if (!file.type.startsWith('image/')) { reject(new ApiError('bad_type', 'That is not an image file.')); return; } const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); if (scale === 1 && file.size < 2_000_000) { resolve({ file, width: img.width, height: img.height, dataUrl: null }); return; } const canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); const ctx = canvas.getContext('2d'); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { const resized = new File([blob], (file.name || 'image').replace(/\.\w+$/, '') + '.jpg', { type: 'image/jpeg' }); resolve({ file: resized, width: canvas.width, height: canvas.height, dataUrl: canvas.toDataURL('image/jpeg', 0.9), }); }, 'image/jpeg', 0.9); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new ApiError('bad_image', 'That image could not be decoded.')); }; img.src = url; }); } function fileToDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new ApiError('bad_image', 'Could not read that file.')); reader.readAsDataURL(file); }); } const b64img = (b64) => 'data:image/jpeg;base64,' + b64; /* ====================================================================== DROPZONE — drag/drop, click, paste, and optional camera capture ====================================================================== */ function initDropzone(el, onFile) { const input = el.querySelector('input[type="file"]'); if (!input) return; input.addEventListener('change', () => { if (input.files && input.files[0]) onFile(input.files[0]); }); ['dragenter', 'dragover'].forEach((evt) => el.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); el.classList.add('is-dragging'); })); ['dragleave', 'drop'].forEach((evt) => el.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); if (evt === 'dragleave' && el.contains(e.relatedTarget)) return; el.classList.remove('is-dragging'); })); el.addEventListener('drop', (e) => { const dt = e.dataTransfer; if (dt && dt.files && dt.files[0]) onFile(dt.files[0]); }); // Paste an image straight from the clipboard document.addEventListener('paste', (e) => { if (!el.isConnected || !e.clipboardData) return; const item = Array.from(e.clipboardData.items || []) .find((i) => i.type.startsWith('image/')); if (item) { const file = item.getAsFile(); if (file) onFile(file); } }); // Keyboard: the dropzone is a label wrapping the input, so Enter/Space // should open the picker like a button would. el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } }); } /* ====================================================================== EXPORTS ====================================================================== */ Object.assign(AIP, { api: { request, run, cancel, health, ApiError, MESSAGES }, request, run, cancel, health, ApiError, state, prepareImage, fileToDataURL, b64img, initDropzone, }); })();