Spaces:
Running
Running
| /* 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 <!doctype html>. Include <html>, <head> with <meta charset> and a | |
| responsive viewport meta, <title>, 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(); | |