// Call diagnostics for the model/media backends (image, voice, skill/text generation). // Every call gets: a timeout (so a hung backend FAILS LOUDLY instead of spinning forever), // timing, and a structured console log + a ring buffer you can dump with `tinyDiag()` in the // devtools console. This is the troubleshooting surface for "stuck at loading" / silent failures. const RING = [] const MAX = 80 const listeners = new Set() const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) const fmt = (ms) => (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms) + 'ms') export function onDiag(fn) { listeners.add(fn); return () => listeners.delete(fn) } export function recentCalls() { return RING.slice() } function record(e) { RING.push(e); if (RING.length > MAX) RING.shift() for (const fn of listeners) { try { fn(e) } catch { /* ignore */ } } } const tagOf = (kind) => `[tinyarmy:${kind}]` // Wrap a non-streaming fetch (image / voice) with a timeout + logging. Returns the Response. // Throws a clear, timing-stamped error on failure/timeout so callers can surface it. export async function diagFetch(kind, url, opts = {}, { timeoutMs = 90000, label } = {}) { const ctrl = new AbortController() if (opts.signal) { if (opts.signal.aborted) ctrl.abort(); else opts.signal.addEventListener('abort', () => ctrl.abort(), { once: true }) } const timer = setTimeout(() => ctrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs) const t0 = nowMs(); const tag = tagOf(kind); const what = `${opts.method || 'GET'} ${url}${label ? ` · ${label}` : ''}` console.info(`${tag} → ${what}`) try { const res = await fetch(url, { ...opts, signal: ctrl.signal }) const ms = nowMs() - t0 if (!res.ok) { console.warn(`${tag} ✗ ${res.status} in ${fmt(ms)} · ${url}`) record({ kind, url, label, ok: false, status: res.status, ms, ts: Date.now() }) } else { console.info(`${tag} ✓ ${res.status} in ${fmt(ms)} · ${url}`) record({ kind, url, label, ok: true, status: res.status, ms, ts: Date.now() }) } return res } catch (e) { const ms = nowMs() - t0 const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError') const msg = timedOut ? `TIMEOUT after ${fmt(ms)}` : (e && e.message ? e.message : String(e)) console.error(`${tag} ✗ ${msg} · ${url}`) record({ kind, url, label, ok: false, error: msg, timedOut, ms, ts: Date.now() }) if (timedOut) throw new Error(`${kind} timed out after ${fmt(ms)} — the backend (${url}) is slow or unresponsive`) throw e } finally { clearTimeout(timer) } } // Lifecycle logging for a streaming call (SSE). Returns handles the streamer calls as it goes. // Logs start / first-token latency / done / error with timing, and records the outcome. export function diagStream(kind, url, label) { const t0 = nowMs(); const tag = tagOf(kind); const what = `${url}${label ? ` · ${label}` : ''}` let first = null console.info(`${tag} ⇢ stream ${what}`) return { firstToken() { if (first == null) { first = nowMs() - t0; console.info(`${tag} · first token in ${fmt(first)} · ${url}`) } }, done(extra) { const ms = nowMs() - t0; console.info(`${tag} ✓ stream done in ${fmt(ms)}${extra ? ' · ' + extra : ''} · ${url}`); record({ kind, url, label, ok: true, ms, firstMs: first, ts: Date.now() }) }, fail(e, timedOut) { const ms = nowMs() - t0; const msg = timedOut ? `TIMEOUT after ${fmt(ms)}` : (e && e.message ? e.message : String(e)); console.error(`${tag} ✗ stream ${msg} · ${url}`); record({ kind, url, label, ok: false, error: msg, timedOut, ms, firstMs: first, ts: Date.now() }) }, } } // Dump recent calls to the console (call `tinyDiag()` in devtools to troubleshoot). function dump() { const rows = RING.map((e) => ({ when: new Date(e.ts).toLocaleTimeString(), kind: e.kind, ok: e.ok ? '✓' : '✗', status: e.status || (e.timedOut ? 'TIMEOUT' : (e.error ? 'ERR' : '')), ms: fmt(e.ms || 0), detail: e.label || e.error || e.url })) try { console.table(rows) } catch { console.log(rows) } return RING.slice() } try { if (typeof window !== 'undefined') window.tinyDiag = dump } catch { /* ignore */ }