html-studio / app.js
fordnox's picture
Upload folder using huggingface_hub
8da8a3a verified
Raw
History Blame Contribute Delete
21.1 kB
/* 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", theme: "hs.theme" };
/* Models the router serves that are strong at writing a whole page in one
* shot. Everything 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"), theme: $("theme"),
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"), deploylabel: $("deploylabel"), newsite: $("newsite"), deployresult: $("deployresult"),
toast: $("toast"), toastmsg: $("toastmsg"), toastdot: $("toastdot"),
};
/* ── state ───────────────────────────────────────────────────────── */
let auth = null; // { accessToken, expiresAt, user } — from HF OAuth
let rawToken = ""; // manually pasted token
let site = null; // { url, claimUrl, subdomain, deployToken }
let controller = null; // AbortController for the in-flight generation
let editTimer = 0;
const token = () => auth?.accessToken || rawToken || "";
/* ── helpers ─────────────────────────────────────────────────────── */
function toast(msg, tone = "live") {
el.toastmsg.textContent = msg;
el.toastdot.className = `dot is-${tone}`;
el.toast.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.toast.hidden = true; }, 4000);
}
function status(msg, kind = "") {
el.status.className = "status" + (kind ? ` is-${kind}` : "");
el.status.innerHTML = "";
if (!msg) return;
const dot = document.createElement("span");
dot.className = "dot " + (kind === "err" ? "is-err" : kind === "ok" ? "is-live" : kind === "busy" ? "is-building" : "");
if (kind) el.status.append(dot);
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 {} },
};
/* Machine values are always mono, and never given more precision than we have. */
function fmtBytes(n) {
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(2)} MB`;
}
const fmtSecs = (ms) => `${(ms / 1000).toFixed(1)}s`;
/* ── theme ───────────────────────────────────────────────────────── */
function applyTheme(t) {
if (t === "paper") document.documentElement.setAttribute("data-theme", "paper");
else document.documentElement.removeAttribute("data-theme");
store.set(LS.theme, t);
}
el.theme.addEventListener("click", () => {
applyTheme(document.documentElement.getAttribute("data-theme") === "paper" ? "ink" : "paper");
});
/* ── 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() {
el.generate.disabled = !token() || !el.model.value || !!controller;
if (!token()) status("sign in to generate", "");
else if (!controller) status("");
}
async function initAuth() {
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);
}
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);
}
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 and 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 needs this app to run as a Hugging Face Space — paste a token instead.", "err");
el.tokenrow.hidden = false;
el.token.focus();
}
});
el.signout.addEventListener("click", () => {
auth = null;
store.del(LS.auth);
renderAuth();
toast("Signed out.", "idle");
});
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.", "idle"); }
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 = "Router model list unreachable — 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("Router model list")) {
el.modelhint.textContent = `${ids.length} open-weights chat models on the Hugging Face 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 first.", "err"); return; }
if (!token()) { toast("Sign in with Hugging Face first.", "err"); 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");
const started = performance.now();
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 another model or rephrase the prompt.");
el.code.value = html;
el.bytes.textContent = fmtBytes(html.length);
renderPreview(html);
showTab("preview");
status(`${fmtBytes(html.length)} · ${fmtSecs(performance.now() - started)}`, "ok");
el.deploy.disabled = false;
} catch (e) {
if (e.name === "AbortError") {
// Keep whatever streamed in; a partial document is 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("failed", "err");
toast(String(e.message || e), "err");
}
} 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.", "err"); 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.", "err"); 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";
}
function renderDeployResult(r, { updated = false, restored = false, ms = 0, bytes = 0 } = {}) {
const lead = restored ? "last deploy" : updated ? "updated" : "live";
const meta = restored ? "" : ` · ${fmtBytes(bytes)}${ms ? ` · ${fmtSecs(ms)}` : ""}`;
el.deployresult.className = "deployresult";
el.deployresult.innerHTML = `
<span class="live"><span class="dot is-live"></span>${lead}${meta}</span>
<a href="${r.url}" target="_blank" rel="noopener">${r.url}</a>
<button class="copy" type="button" data-copy="${r.url}">copy</button>
<span class="claim-note">
Private claim link — open it and sign in to keep this site. It cannot be recovered if lost,
and an unclaimed site expires 24 hours after its last deploy.<br>
<a href="${r.claimUrl}" target="_blank" rel="noopener">${r.claimUrl}</a>
<button class="copy" type="button" data-copy="${r.claimUrl}">copy</button>
</span>`;
el.newsite.hidden = false;
el.deploylabel.textContent = "Update site";
}
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.", "err"));
});
el.deploy.addEventListener("click", async () => {
const html = el.code.value.trim();
if (!html) { toast("Generate something first.", "err"); return; }
el.deploy.disabled = true;
status("deploying", "busy");
const started = performance.now();
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, 160)}`); }
if (!res.ok) throw new Error(r.error || r.message || `harvis.dev returned ${res.status}`);
const ms = performance.now() - started;
site = { url: r.url, claimUrl: r.claimUrl, subdomain: r.subdomain, deployToken: r.deployToken };
store.set(LS.site, site);
renderDeployResult(r, { updated: !!r.updated, ms, bytes: html.length });
status(`${fmtBytes(html.length)} · ${fmtSecs(ms)}`, "ok");
// No success toast: it is fixed bottom-right and would cover the claim link
// at the exact moment it appears. The deploy bar already reports the result.
} catch (e) {
console.error("[deploy]", e);
el.deployresult.className = "deployresult is-err";
el.deployresult.textContent = String(e.message || e);
status("failed", "err");
toast("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.deploylabel.textContent = "Deploy to harvis.dev";
toast("Next deploy creates a new site.", "idle");
});
/* ── examples ────────────────────────────────────────────────────── */
for (const tag of document.querySelectorAll(".tag")) {
tag.addEventListener("click", () => {
el.prompt.value = tag.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 ────────────────────────────────────────────────────────── */
applyTheme(store.get(LS.theme) || "ink");
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, { restored: true });
initAuth();
loadModels();