/* HTML Studio → harvis.dev * Runs entirely in the browser. Two APIs, both CORS-open and called directly: * - https://router.huggingface.co/v1 (OpenAI-compatible, needs an HF token) * - https://harvis.dev/api/upload (static hosting, no auth) */ import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://esm.sh/@huggingface/hub@2.15.0"; const ROUTER = "https://router.huggingface.co/v1"; const HARVIS = "https://harvis.dev/api/upload"; const LS = { auth: "hs.auth", token: "hs.token", model: "hs.model", site: "hs.site", prompt: "hs.prompt" }; /* Models the router serves that are strong at writing a whole page in one * shot. Anything else on the router is still selectable below this group. */ const RECOMMENDED = [ "Qwen/Qwen3-Coder-Next", "moonshotai/Kimi-K2.7-Code", "zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V4-Flash", "openai/gpt-oss-120b", "Qwen/Qwen3-Coder-30B-A3B-Instruct", "MiniMaxAI/MiniMax-M3", "google/gemma-4-31B-it", ]; const DEFAULT_SYSTEM = `You are an expert front-end engineer with a strong sense of visual design. Return ONE complete, self-contained HTML document and NOTHING else. Rules: - Start with . Include , with and a responsive viewport meta, , and <body>. - Inline ALL CSS in a <style> tag and ALL JavaScript in a <script> tag. The file must work when opened on its own, with no build step and no local assets. - No markdown code fences, no explanation, no commentary before or after. - External resources are allowed only from public CDNs (unpkg, jsdelivr, fonts.googleapis.com) or picsum.photos for placeholder imagery. Prefer inline SVG and CSS over dependencies. - Make it genuinely well designed: a deliberate type scale, real spacing rhythm, a coherent palette, hover and focus states, and a sensible responsive layout. - Semantic HTML, accessible contrast, alt text, and keyboard-usable controls.`; /* ── element handles ─────────────────────────────────────────────── */ const $ = (id) => document.getElementById(id); const el = { signin: $("signin"), signout: $("signout"), tokentoggle: $("tokentoggle"), whoami: $("whoami"), avatar: $("avatar"), username: $("username"), tokenrow: $("tokenrow"), token: $("token"), tokensave: $("tokensave"), model: $("model"), modelhint: $("modelhint"), prompt: $("prompt"), system: $("system"), temp: $("temp"), tempval: $("tempval"), maxtok: $("maxtok"), generate: $("generate"), stop: $("stop"), tabPreview: $("tab-preview"), tabCode: $("tab-code"), panePreview: $("pane-preview"), paneCode: $("pane-code"), preview: $("preview"), code: $("code"), empty: $("empty"), bytes: $("bytes"), status: $("status"), reload: $("reload"), download: $("download"), deploy: $("deploy"), newsite: $("newsite"), deployresult: $("deployresult"), toast: $("toast"), }; /* ── state ───────────────────────────────────────────────────────── */ let auth = null; // { accessToken, expiresAt, user: {name, avatarUrl} } — from OAuth let rawToken = ""; // manually pasted token let site = null; // { url, claimUrl, subdomain, deployToken } let controller = null; // AbortController for the in-flight generation let previewTimer = 0, editTimer = 0; const token = () => auth?.accessToken || rawToken || ""; /* ── tiny helpers ────────────────────────────────────────────────── */ function toast(msg, isErr = false) { el.toast.textContent = msg; el.toast.classList.toggle("is-err", isErr); el.toast.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => { el.toast.hidden = true; }, 3600); } function status(msg, kind = "") { el.status.className = "status" + (kind ? ` is-${kind}` : ""); el.status.innerHTML = ""; if (kind === "busy") el.status.append(Object.assign(document.createElement("span"), { className: "spinner" })); if (msg) el.status.append(document.createTextNode(msg)); } const store = { get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, del(k) { try { localStorage.removeItem(k); } catch {} }, }; function fmtBytes(n) { return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} kB` : `${(n / 1048576).toFixed(2)} MB`; } /* ── auth ────────────────────────────────────────────────────────── */ function renderAuth() { const signedIn = !!auth?.accessToken; el.whoami.hidden = !signedIn; el.signin.hidden = signedIn; el.tokentoggle.hidden = signedIn; if (signedIn) { el.username.textContent = auth.user?.name || "signed in"; if (auth.user?.avatarUrl) { el.avatar.src = auth.user.avatarUrl; el.avatar.hidden = false; } else el.avatar.hidden = true; } syncGenerateEnabled(); } function syncGenerateEnabled() { const ready = !!token() && !!el.model.value; el.generate.disabled = !ready || !!controller; if (!token()) status("Sign in with Hugging Face (or paste a token) to generate.", ""); else if (!controller) status(""); } async function initAuth() { // 1. Coming back from the HF OAuth redirect? try { const res = await oauthHandleRedirectIfPresent(); if (res) { auth = { accessToken: res.accessToken, expiresAt: res.accessTokenExpiresAt ? new Date(res.accessTokenExpiresAt).getTime() : 0, user: { name: res.userInfo?.name || res.userInfo?.preferred_username, avatarUrl: res.userInfo?.avatarUrl }, }; store.set(LS.auth, auth); history.replaceState(null, "", location.pathname); toast(`Signed in as ${auth.user.name}`); } } catch (e) { console.warn("[auth] redirect handling failed:", e); } // 2. Restore a still-valid session. if (!auth) { const saved = store.get(LS.auth); if (saved?.accessToken && (!saved.expiresAt || saved.expiresAt > Date.now() + 60_000)) auth = saved; else store.del(LS.auth); } // 3. Or a manually pasted token. rawToken = store.get(LS.token) || ""; if (rawToken) el.token.value = rawToken; renderAuth(); } el.signin.addEventListener("click", async () => { try { // Inside a Space with `hf_oauth: true`, client id + redirect are injected // by the platform, so no arguments are needed. location.href = await oauthLoginUrl({ scopes: "openid profile inference-api" }); } catch (e) { console.warn("[auth] oauthLoginUrl failed:", e); toast("OAuth is only available when this app runs as a Hugging Face Space. Paste a token instead.", true); el.tokenrow.hidden = false; el.token.focus(); } }); el.signout.addEventListener("click", () => { auth = null; store.del(LS.auth); renderAuth(); toast("Signed out"); }); el.tokentoggle.addEventListener("click", () => { el.tokenrow.hidden = !el.tokenrow.hidden; if (!el.tokenrow.hidden) el.token.focus(); }); el.tokensave.addEventListener("click", () => { rawToken = el.token.value.trim(); if (rawToken) { store.set(LS.token, rawToken); toast("Token saved to this browser"); el.tokenrow.hidden = true; } else { store.del(LS.token); toast("Token cleared"); } syncGenerateEnabled(); }); el.token.addEventListener("keydown", (e) => { if (e.key === "Enter") el.tokensave.click(); }); /* ── models ──────────────────────────────────────────────────────── */ async function loadModels() { let ids = []; try { const r = await fetch(`${ROUTER}/models`); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); ids = (j.data || j || []).map((m) => m.id).filter(Boolean); } catch (e) { console.warn("[models] router list unavailable, using fallback:", e); ids = RECOMMENDED.slice(); el.modelhint.textContent = "Couldn't reach the router's model list — showing a fallback set."; } const available = new Set(ids); const top = RECOMMENDED.filter((m) => available.has(m)); const rest = ids.filter((m) => !top.includes(m)).sort((a, b) => a.localeCompare(b)); el.model.innerHTML = ""; const group = (label, items) => { if (!items.length) return; const g = document.createElement("optgroup"); g.label = label; for (const id of items) g.append(new Option(id, id)); el.model.append(g); }; group("Recommended for HTML", top); group(`All models (${rest.length})`, rest); const saved = store.get(LS.model); el.model.value = (saved && available.has(saved)) ? saved : (top[0] || rest[0] || ""); if (!el.modelhint.textContent.startsWith("Couldn't")) { el.modelhint.textContent = `${ids.length} open-weights chat models on the HF Inference Providers router.`; } syncGenerateEnabled(); } el.model.addEventListener("change", () => { store.set(LS.model, el.model.value); syncGenerateEnabled(); }); /* ── generation ──────────────────────────────────────────────────── */ function extractHtml(raw) { if (!raw) return ""; let t = raw; const fence = t.match(/```(?:html|xml)?[ \t]*\r?\n([\s\S]*?)(?:\r?\n[ \t]*```|$)/i); if (fence) t = fence[1]; else { const i = t.search(/<!doctype html|<html[\s>]/i); if (i > 0) t = t.slice(i); } return t.replace(/[ \t]*```[ \t]*$/, "").trim(); } async function generate() { const prompt = el.prompt.value.trim(); if (!prompt) { el.prompt.focus(); toast("Describe the site you want first.", true); return; } if (!token()) { toast("Sign in with Hugging Face first.", true); return; } controller = new AbortController(); el.generate.hidden = true; el.stop.hidden = false; el.deploy.disabled = true; store.set(LS.prompt, prompt); showTab("code"); el.code.value = ""; status("Generating…", "busy"); let raw = ""; let lastPreview = 0; try { const res = await fetch(`${ROUTER}/chat/completions`, { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token()}` }, body: JSON.stringify({ model: el.model.value, stream: true, temperature: parseFloat(el.temp.value), max_tokens: parseInt(el.maxtok.value, 10) || 16000, messages: [ { role: "system", content: el.system.value.trim() || DEFAULT_SYSTEM }, { role: "user", content: prompt }, ], }), }); if (!res.ok) { const detail = (await res.text().catch(() => "")).slice(0, 400); throw new Error(`Router returned ${res.status}. ${detail}`); } const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop(); for (const line of lines) { const s = line.trim(); if (!s.startsWith("data:")) continue; const payload = s.slice(5).trim(); if (!payload || payload === "[DONE]") continue; let chunk; try { chunk = JSON.parse(payload); } catch { continue; } if (chunk.error) throw new Error(chunk.error.message || JSON.stringify(chunk.error)); // `reasoning_content` from thinking models is deliberately ignored. const delta = chunk.choices?.[0]?.delta?.content; if (!delta) continue; raw += delta; el.code.value = raw; el.code.scrollTop = el.code.scrollHeight; el.bytes.textContent = fmtBytes(raw.length); // Throttled progressive render — partial documents render fine. const now = performance.now(); if (now - lastPreview > 1500 && /<body[\s>]/i.test(raw)) { lastPreview = now; renderPreview(extractHtml(raw)); } } } const html = extractHtml(raw); if (!html) throw new Error("The model returned no HTML. Try a different model or rephrase the prompt."); el.code.value = html; el.bytes.textContent = fmtBytes(html.length); renderPreview(html); showTab("preview"); status(`Done — ${fmtBytes(html.length)}`, "ok"); el.deploy.disabled = false; } catch (e) { if (e.name === "AbortError") { // Keep whatever streamed in; it's often still usable. const html = extractHtml(raw); if (html) { el.code.value = html; renderPreview(html); el.deploy.disabled = false; } status("Stopped", ""); } else { console.error("[generate]", e); status(String(e.message || e), "err"); toast(String(e.message || e), true); } } finally { controller = null; el.generate.hidden = false; el.stop.hidden = true; syncGenerateEnabled(); } } el.generate.addEventListener("click", generate); el.stop.addEventListener("click", () => controller?.abort()); el.prompt.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); if (!el.generate.disabled) generate(); } }); /* ── preview ─────────────────────────────────────────────────────── */ function renderPreview(html) { if (!html) return; el.empty.hidden = true; el.preview.hidden = false; el.preview.srcdoc = html; } el.reload.addEventListener("click", () => { const html = el.code.value.trim(); if (!html) { toast("Nothing to render yet.", true); return; } renderPreview(html); showTab("preview"); }); el.code.addEventListener("input", () => { el.bytes.textContent = fmtBytes(el.code.value.length); el.deploy.disabled = !el.code.value.trim(); clearTimeout(editTimer); editTimer = setTimeout(() => { if (el.code.value.trim()) renderPreview(el.code.value); }, 700); }); /* ── tabs ────────────────────────────────────────────────────────── */ function showTab(which) { const isPreview = which === "preview"; el.tabPreview.classList.toggle("is-active", isPreview); el.tabCode.classList.toggle("is-active", !isPreview); el.tabPreview.setAttribute("aria-selected", String(isPreview)); el.tabCode.setAttribute("aria-selected", String(!isPreview)); el.panePreview.hidden = !isPreview; el.paneCode.hidden = isPreview; } el.tabPreview.addEventListener("click", () => showTab("preview")); el.tabCode.addEventListener("click", () => showTab("code")); /* ── download ────────────────────────────────────────────────────── */ el.download.addEventListener("click", () => { const html = el.code.value.trim(); if (!html) { toast("Nothing to download yet.", true); return; } const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); const a = Object.assign(document.createElement("a"), { href: url, download: "index.html" }); a.click(); URL.revokeObjectURL(url); }); /* ── deploy ──────────────────────────────────────────────────────── */ function siteName() { const p = el.prompt.value.trim().split("\n")[0].slice(0, 48).trim(); return p || "html-studio-site"; } function renderDeployResult(r, updated) { el.deployresult.className = "deployresult"; el.deployresult.innerHTML = ` <span class="live">${updated ? "Updated" : "Live"}: <a href="${r.url}" target="_blank" rel="noopener">${r.url}</a></span> <button class="copy" type="button" data-copy="${r.url}">Copy</button> <span class="claim-note"> Private claim link — open it and sign in (free) to keep this site, it can't be recovered if lost: <a href="${r.claimUrl}" target="_blank" rel="noopener">${r.claimUrl}</a> <button class="copy" type="button" data-copy="${r.claimUrl}">Copy</button> <br>Unclaimed sites expire 24 hours after the last deploy. </span>`; el.newsite.hidden = false; el.deploy.textContent = "Update site on harvis.dev"; } el.deployresult.addEventListener("click", (e) => { const btn = e.target.closest("[data-copy]"); if (!btn) return; navigator.clipboard.writeText(btn.dataset.copy).then(() => toast("Copied"), () => toast("Copy failed", true)); }); el.deploy.addEventListener("click", async () => { const html = el.code.value.trim(); if (!html) { toast("Generate something first.", true); return; } el.deploy.disabled = true; status("Deploying to harvis.dev…", "busy"); const body = { name: siteName(), files: [{ path: "index.html", content: html, encoding: "text" }], }; if (site?.subdomain && site?.deployToken) { body.subdomain = site.subdomain; body.deployToken = site.deployToken; } try { const res = await fetch(HARVIS, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await res.text(); let r; try { r = JSON.parse(text); } catch { throw new Error(`harvis.dev returned ${res.status}: ${text.slice(0, 200)}`); } if (!res.ok) throw new Error(r.error || r.message || `harvis.dev returned ${res.status}`); site = { url: r.url, claimUrl: r.claimUrl, subdomain: r.subdomain, deployToken: r.deployToken }; store.set(LS.site, site); renderDeployResult(r, !!r.updated); status(r.updated ? "Site updated" : "Site published", "ok"); toast(r.updated ? "Site updated" : "Site is live 🎉"); } catch (e) { console.error("[deploy]", e); el.deployresult.className = "deployresult is-err"; el.deployresult.textContent = String(e.message || e); status("Deploy failed", "err"); } finally { el.deploy.disabled = false; } }); el.newsite.addEventListener("click", () => { site = null; store.del(LS.site); el.deployresult.className = "deployresult"; el.deployresult.textContent = ""; el.newsite.hidden = true; el.deploy.textContent = "Deploy to harvis.dev"; toast("Next deploy will create a brand-new site"); }); /* ── examples ────────────────────────────────────────────────────── */ for (const chip of document.querySelectorAll(".chip")) { chip.addEventListener("click", () => { el.prompt.value = chip.dataset.example; el.prompt.focus(); store.set(LS.prompt, el.prompt.value); }); } /* ── misc wiring ─────────────────────────────────────────────────── */ el.temp.addEventListener("input", () => { el.tempval.textContent = parseFloat(el.temp.value).toFixed(2); }); el.prompt.addEventListener("change", () => store.set(LS.prompt, el.prompt.value)); /* ── boot ────────────────────────────────────────────────────────── */ el.system.value = DEFAULT_SYSTEM; el.tempval.textContent = parseFloat(el.temp.value).toFixed(2); el.prompt.value = store.get(LS.prompt) || ""; site = store.get(LS.site); if (site?.url) { renderDeployResult(site, true); el.deployresult.querySelector(".live").firstChild.textContent = "Last deploy: "; } initAuth(); loadModels();