html-studio / app.js
fordnox's picture
Upload folder using huggingface_hub
f1b492f verified
Raw
History Blame Contribute Delete
20 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" };
/* 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&nbsp;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();