Spaces:
Running
Running
| /* ========================================================================== | |
| 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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.2v.3"/></svg>', | |
| empty: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15l5-5 4 4 3-3 6 6"/><circle cx="8.5" cy="8.5" r="1.5"/></svg>', | |
| offline: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 3l18 18"/><path d="M12 18v.01"/><path d="M8.5 14.5a5 5 0 0 1 6-.8"/><path d="M5 11a10 10 0 0 1 4-2.4"/><path d="M19 11a10 10 0 0 0-6.5-2.9"/></svg>', | |
| }; | |
| 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 = | |
| '<div class="state" role="status" aria-live="polite">' + | |
| '<div class="progress-line"></div>' + | |
| (steps.length | |
| ? '<div class="steps">' + steps.map((s, i) => | |
| '<div class="step' + (i === 0 ? ' is-active' : '') + '" data-step="' + i + '">' + | |
| escapeHTML(s) + '</div>').join('') + '</div>' | |
| : '') + | |
| '<p class="state__text">' + escapeHTML(opts.text || 'Running the model…') + '</p>' + | |
| (opts.cancel ? '<button class="btn btn--ghost btn--sm" data-cancel>Cancel</button>' : '') + | |
| '</div>'; | |
| 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 = | |
| '<div class="state state--error" role="alert">' + | |
| '<span class="state__icon">' + (isOffline ? ICONS.offline : ICONS.error) + '</span>' + | |
| '<p class="state__title">' + escapeHTML(error.title || 'Something went wrong') + '</p>' + | |
| '<p class="state__text">' + escapeHTML(error.message) + '</p>' + | |
| (error.retryable && onRetry | |
| ? '<button class="btn btn--outline btn--sm" data-retry>Try again</button>' : '') + | |
| '</div>'; | |
| const btn = el.querySelector('[data-retry]'); | |
| if (btn && onRetry) btn.addEventListener('click', onRetry); | |
| }, | |
| empty(el, title, text) { | |
| el.innerHTML = | |
| '<div class="state">' + | |
| '<span class="state__icon">' + ICONS.empty + '</span>' + | |
| '<p class="state__title">' + escapeHTML(title || 'Nothing here yet') + '</p>' + | |
| '<p class="state__text">' + escapeHTML(text || '') + '</p>' + | |
| '</div>'; | |
| }, | |
| }; | |
| /* ====================================================================== | |
| 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, | |
| }); | |
| })(); | |