ai-playground / static /js /api.js
Mukhlis
Redesign as an editorial AI exhibition
a9b35e6
Raw
History Blame Contribute Delete
16.9 kB
/* ==========================================================================
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,
});
})();