Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>DiffusionGemma · Live Website Builder</title> | |
| <style> | |
| :root { | |
| --bg: #0b0e14; | |
| --panel: #121722; | |
| --panel-2: #0f141d; | |
| --border: #222a38; | |
| --text: #e6edf3; | |
| --muted: #8b97a7; | |
| --accent: #7c5cff; | |
| --accent-2: #18c29c; | |
| --amber: #f5c451; | |
| --green: #2ea043; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; height: 100%; } | |
| body { | |
| background: radial-gradient(1200px 600px at 70% -10%, #1a2030 0%, var(--bg) 55%); | |
| color: var(--text); | |
| font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| display: flex; flex-direction: column; height: 100vh; overflow: hidden; | |
| } | |
| header { | |
| padding: 14px 22px; border-bottom: 1px solid var(--border); | |
| display: flex; align-items: center; gap: 14px; flex: 0 0 auto; | |
| } | |
| header .logo { font-size: 22px; } | |
| header h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: .2px; } | |
| header p { margin: 0; color: var(--muted); font-size: 12.5px; } | |
| .pill { | |
| margin-left: auto; font-size: 12px; color: var(--muted); | |
| border: 1px solid var(--border); border-radius: 999px; padding: 5px 12px; | |
| display: flex; align-items: center; gap: 8px; white-space: nowrap; | |
| } | |
| .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } | |
| .dot.live { background: var(--amber); box-shadow: 0 0 8px var(--amber); } | |
| .dot.done { background: var(--green); box-shadow: 0 0 8px var(--green); } | |
| .dot.err { background: #f85149; box-shadow: 0 0 8px #f85149; } | |
| main { flex: 1 1 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 14px 18px; min-height: 0; } | |
| .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; } | |
| .panel .cap { padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 12.5px; font-weight: 600; color: var(--muted); display: flex; align-items: center; gap: 8px; } | |
| .panel .cap .sub { font-weight: 400; color: #5d6b7a; } | |
| /* Code / diffusion view */ | |
| #code { flex: 1 1 auto; overflow: auto; margin: 0; padding: 12px 0; font-family: "SF Mono", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; background: var(--panel-2); } | |
| #code .ln { padding: 0 14px; white-space: pre-wrap; word-break: break-word; min-height: 1.55em; border-left: 3px solid transparent; } | |
| #code .ln.live { animation: flash .6s ease-out; background: rgba(245,196,81,.05); } | |
| #code .ln.diff { background: rgba(46,160,67,.13); border-left-color: var(--green); } | |
| @keyframes flash { | |
| 0% { background: rgba(245,196,81,.42); } | |
| 100% { background: rgba(245,196,81,.05); } | |
| } | |
| #code::-webkit-scrollbar, .scroll::-webkit-scrollbar { width: 9px; height: 9px; } | |
| #code::-webkit-scrollbar-thumb, .scroll::-webkit-scrollbar-thumb { background: #2a3344; border-radius: 8px; } | |
| /* Website preview */ | |
| #preview { flex: 1 1 auto; border: 0; width: 100%; background: #fff; } | |
| /* Bottom dock */ | |
| footer { flex: 0 0 auto; border-top: 1px solid var(--border); padding: 12px 18px; background: var(--panel-2); } | |
| .row { display: flex; gap: 12px; align-items: flex-start; } | |
| textarea#prompt { | |
| flex: 1 1 auto; resize: none; height: 72px; background: var(--panel); color: var(--text); | |
| border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; font-size: 14px; font-family: inherit; | |
| } | |
| textarea#prompt:focus { outline: none; border-color: var(--accent); } | |
| .btns { display: flex; flex-direction: column; gap: 8px; width: 150px; } | |
| button { font-family: inherit; font-size: 13.5px; font-weight: 600; border-radius: 10px; padding: 9px 12px; cursor: pointer; border: 1px solid var(--border); } | |
| button.primary { background: linear-gradient(180deg, #8a6bff, #6b48f0); color: #fff; border: 0; } | |
| button.primary:disabled { opacity: .5; cursor: not-allowed; } | |
| button.ghost { background: transparent; color: var(--muted); } | |
| button.ghost:hover:not(:disabled) { color: var(--text); border-color: #36405230; } | |
| button.ghost:disabled { opacity: .4; cursor: not-allowed; } | |
| .meta { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-top: 11px; color: var(--muted); font-size: 12px; } | |
| .meta label { display: flex; align-items: center; gap: 7px; white-space: nowrap; } | |
| .meta input[type="range"] { accent-color: var(--accent); width: 120px; } | |
| .meta input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; } | |
| .meta .val { color: var(--text); font-variant-numeric: tabular-nums; min-width: 30px; } | |
| .chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 11px; } | |
| .chip { font-size: 12px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 5px 11px; cursor: pointer; background: transparent; } | |
| .chip:hover { color: var(--text); border-color: var(--accent); } | |
| .history { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; max-height: 46px; overflow: auto; } | |
| .turn { font-size: 11.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 8px; padding: 3px 9px; } | |
| .turn b { color: var(--accent-2); } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <span class="logo">🌫️→🌐</span> | |
| <div> | |
| <h1>DiffusionGemma · Live Website Builder</h1> | |
| <p>Describe a website and a block-diffusion LLM writes the HTML by denoising — every token updates at once. Watch the raw canvas take shape (left) while it renders into a live page (right), then send follow-up prompts to tweak it.</p> | |
| </div> | |
| <span class="pill"><span class="dot" id="statusDot"></span><span id="statusText">idle</span></span> | |
| </header> | |
| <main> | |
| <section class="panel"> | |
| <div class="cap">🧠 Model's view — diffusion canvas <span class="sub" id="capInfo"></span></div> | |
| <div id="code" class="scroll"></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="cap">🌐 Live website</div> | |
| <iframe id="preview" sandbox="allow-scripts"></iframe> | |
| </section> | |
| </main> | |
| <footer> | |
| <div class="row"> | |
| <textarea id="prompt" placeholder="Describe a website… e.g. 'a landing page for a coffee shop with a hero, menu, and footer' then tweak: 'make the header dark', 'add a contact form'"></textarea> | |
| <div class="btns"> | |
| <button class="primary" id="buildBtn">Build / Tweak</button> | |
| <button class="ghost" id="resetBtn">Reset</button> | |
| </div> | |
| </div> | |
| <div class="chips" id="chips"></div> | |
| <div class="meta"> | |
| <label>tokens <input type="range" id="maxTokens" min="2048" max="4096" step="256" value="2048"><span class="val" id="maxTokensV">2048</span></label> | |
| <label>iterations/block <input type="range" id="maxIters" min="8" max="120" step="8" value="64"><span class="val" id="maxItersV">64</span></label> | |
| <label>anim delay <input type="range" id="delay" min="0" max="0.3" step="0.02" value="0"><span class="val" id="delayV">0.0s</span></label> | |
| <label><input type="checkbox" id="fullDenoise"> run all denoising steps (no early stop)</label> | |
| <label><input type="checkbox" id="warmStart" checked> tweak in place (diffuse from current page, not noise)</label> | |
| <span class="history" id="history"></span> | |
| </div> | |
| </footer> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const codeEl = $("code"), preview = $("preview"); | |
| const statusDot = $("statusDot"), statusText = $("statusText"), capInfo = $("capInfo"); | |
| const buildBtn = $("buildBtn"), resetBtn = $("resetBtn"), promptEl = $("prompt"); | |
| let client = null; | |
| let busy = false; | |
| let messages = []; // [{role, content}] confirmed conversation | |
| let prevFrameLines = []; // lines shown on the previous streaming frame (live churn diff) | |
| let lastFinalLines = []; // lines of the previous round's final HTML (tweak diff) | |
| let previewScrollY = 0; // remembered scroll position of the rendered page (see renderPreview) | |
| const EXAMPLES = [ | |
| "A bold landing page for a startup called 'Nimbus' that sells AI weather forecasting, with a hero section, three feature cards, and a call-to-action button.", | |
| "A cozy personal blog homepage with a warm color palette, a header, an about section, and a list of three recent posts.", | |
| "A neon synthwave portfolio page for a music producer with an animated gradient background.", | |
| "A clean pricing page with three tiers (Free, Pro, Team), a feature comparison, and a FAQ.", | |
| "A restaurant landing page with a hero image area, a menu grid, opening hours, and a reservation button.", | |
| "A sleek product page for wireless headphones with specs, an image placeholder, and an add-to-cart bar.", | |
| "A dark-mode dashboard mockup with a sidebar, four stat cards, and a placeholder chart.", | |
| "A playful 'coming soon' page with a countdown vibe, an email signup box, and floating shapes.", | |
| ]; | |
| // The rendered page reports its scroll position back to us via postMessage so we can | |
| // restore it after each re-render (the iframe reloads every frame as the page diffuses, | |
| // which would otherwise snap the user back to the top mid-stream). | |
| window.addEventListener("message", (e) => { | |
| if (e.data && typeof e.data.__gdiffScrollY === "number") previewScrollY = e.data.__gdiffScrollY; | |
| }); | |
| // ---------- helpers ---------- | |
| function esc(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } | |
| function setStatus(kind, text) { | |
| statusDot.className = "dot" + (kind ? " " + kind : ""); | |
| statusText.textContent = text; | |
| } | |
| // Longest-common-subsequence over lines -> set of indices in `b` that are unchanged. | |
| function unchangedSet(a, b) { | |
| const n = a.length, m = b.length; | |
| const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1)); | |
| for (let i = n - 1; i >= 0; i--) | |
| for (let j = m - 1; j >= 0; j--) | |
| dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| const keep = new Set(); | |
| let i = 0, j = 0; | |
| while (i < n && j < m) { | |
| if (a[i] === b[j]) { keep.add(j); i++; j++; } | |
| else if (dp[i + 1][j] >= dp[i][j + 1]) i++; else j++; | |
| } | |
| return keep; | |
| } | |
| // Render the source with per-line highlight classes. | |
| function renderCode(source, { liveAgainst = null, diffAgainst = null } = {}) { | |
| const lines = source.split("\n"); | |
| const liveKeep = liveAgainst ? unchangedSet(liveAgainst, lines) : null; | |
| const diffKeep = diffAgainst ? unchangedSet(diffAgainst, lines) : null; | |
| const html = lines.map((ln, idx) => { | |
| let cls = "ln"; | |
| if (diffKeep && !diffKeep.has(idx)) cls += " diff"; // persistent tweak diff | |
| else if (liveKeep && !liveKeep.has(idx)) cls += " live"; // transient churn flash | |
| return `<div class="${cls}">${esc(ln) || " "}</div>`; | |
| }).join(""); | |
| codeEl.innerHTML = html; | |
| return lines; | |
| } | |
| // Repair a (possibly front-mangled) document so the preview never breaks. Warm-start | |
| // diffusion often eats the leading chars of the header ("<!DOCTYPE html>" -> "DOCTYPE>", | |
| // "<html" -> "<head"); rebuild whatever was eaten by anchoring on the first intact | |
| // structural tag. Mirrors the server's extract_html. | |
| function normalizeHtml(src) { | |
| const lower = src.toLowerCase(); | |
| const dt = lower.indexOf("<!doctype"); | |
| if (dt !== -1) return src.slice(dt); | |
| const h = lower.indexOf("<html"); | |
| if (h !== -1) return "<!DOCTYPE html>\n" + src.slice(h); | |
| const hd = lower.indexOf("<head"); | |
| if (hd !== -1) return '<!DOCTYPE html>\n<html lang="en">\n' + src.slice(hd); | |
| const bd = lower.indexOf("<body"); | |
| if (bd !== -1) return '<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="UTF-8"></head>\n' + src.slice(bd); | |
| return src; | |
| } | |
| // Render the model's HTML into the live preview, repairing the header and preserving the | |
| // user's scroll position across re-renders (each frame reloads the iframe). A tiny script | |
| // is injected before </body>: it restores the remembered scroll and reports new positions. | |
| function renderPreview(source) { | |
| if (!source || !source.trim()) { preview.srcdoc = ""; previewScrollY = 0; return; } | |
| source = normalizeHtml(source); | |
| const y = Math.round(previewScrollY); | |
| const inject = | |
| "<script>(function(){var y=" + y + ";" + | |
| "function r(){window.scrollTo(0,y);}" + | |
| "r();requestAnimationFrame(r);window.addEventListener('load',r);" + | |
| "window.addEventListener('scroll',function(){" + | |
| "parent.postMessage({__gdiffScrollY:window.scrollY||document.documentElement.scrollTop||0},'*');" + | |
| "},{passive:true});})();<\/script>"; | |
| const idx = source.toLowerCase().lastIndexOf("</body>"); | |
| preview.srcdoc = idx !== -1 ? source.slice(0, idx) + inject + source.slice(idx) : source + inject; | |
| } | |
| // ---------- generation ---------- | |
| async function ensureClient() { | |
| if (!client) { setStatus("", "connecting…"); client = await Client.connect(window.location.origin); } | |
| return client; | |
| } | |
| async function run() { | |
| if (busy) return; | |
| const prompt = promptEl.value.trim(); | |
| if (!prompt) { promptEl.focus(); return; } | |
| busy = true; buildBtn.disabled = true; | |
| const isTweak = messages.length > 0; | |
| setStatus("live", isTweak ? "tweaking…" : "diffusing…"); | |
| prevFrameLines = []; | |
| if (!isTweak) previewScrollY = 0; // fresh build starts at the top | |
| try { | |
| const c = await ensureClient(); | |
| const payload = { | |
| prompt, | |
| history_json: JSON.stringify(messages), | |
| max_new_tokens: parseInt($("maxTokens").value, 10), | |
| max_iters: parseInt($("maxIters").value, 10), | |
| full_denoise: $("fullDenoise").checked, | |
| anim_delay: parseFloat($("delay").value), | |
| warm_start: $("warmStart").checked, | |
| }; | |
| let finalSource = ""; | |
| const sub = c.submit("/generate", payload); | |
| for await (const ev of sub) { | |
| if (ev.type === "data") { | |
| const frame = JSON.parse(ev.data[0]); | |
| if (frame.kind === "error") { setStatus("err", "error"); renderCode("/* " + frame.message + " */"); break; } | |
| if (frame.kind === "done") { | |
| finalSource = frame.source; | |
| // Persistent green highlight of what changed vs the previous round. | |
| prevFrameLines = renderCode(finalSource, { diffAgainst: isTweak ? lastFinalLines : null }); | |
| renderPreview(finalSource); | |
| setStatus("done", "done"); | |
| continue; | |
| } | |
| // draft / commit frame: both the code panel and the live page churn every frame. | |
| const src = frame.source || ""; | |
| prevFrameLines = renderCode(src, { liveAgainst: prevFrameLines }); | |
| renderPreview(src); | |
| capInfo.textContent = `block ${frame.block} · step ${frame.step}/${frame.max_iters} · ${frame.canvas} tokens update simultaneously`; | |
| setStatus("live", `${frame.kind === "draft" ? "diffusing" : "committed"} · block ${frame.block} · step ${frame.step}`); | |
| } else if (ev.type === "status" && ev.stage === "error") { | |
| setStatus("err", "error"); break; | |
| } | |
| } | |
| if (finalSource) { | |
| messages.push({ role: "user", content: prompt }); | |
| messages.push({ role: "assistant", content: finalSource }); | |
| lastFinalLines = finalSource.split("\n"); | |
| promptEl.value = ""; | |
| renderHistory(); | |
| } | |
| } catch (e) { | |
| setStatus("err", "error"); | |
| renderCode("/* connection error: " + (e && e.message ? e.message : e) + " */"); | |
| } finally { | |
| busy = false; buildBtn.disabled = false; | |
| } | |
| } | |
| function renderHistory() { | |
| const turns = messages.filter((m) => m.role === "user"); | |
| $("history").innerHTML = turns.map((m, i) => | |
| `<span class="turn"><b>${i === 0 ? "build" : "tweak " + i}</b> · ${esc(m.content.slice(0, 40))}${m.content.length > 40 ? "…" : ""}</span>` | |
| ).join(""); | |
| } | |
| function reset() { | |
| messages = []; prevFrameLines = []; lastFinalLines = []; previewScrollY = 0; | |
| codeEl.innerHTML = ""; renderPreview(""); $("history").innerHTML = ""; | |
| capInfo.textContent = ""; setStatus("", "idle"); promptEl.value = ""; | |
| } | |
| // ---------- wiring ---------- | |
| buildBtn.addEventListener("click", run); | |
| resetBtn.addEventListener("click", reset); | |
| promptEl.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); run(); } | |
| }); | |
| for (const [id, fmt] of [["maxTokens", (v) => v], ["maxIters", (v) => v], ["delay", (v) => (+v).toFixed(1) + "s"]]) { | |
| const el = $(id), out = $(id + "V"); | |
| el.addEventListener("input", () => (out.textContent = fmt(el.value))); | |
| } | |
| $("chips").innerHTML = EXAMPLES.map((e, i) => `<button class="chip" data-i="${i}">${esc(e.slice(0, 42))}…</button>`).join(""); | |
| $("chips").addEventListener("click", (e) => { | |
| const i = e.target.getAttribute("data-i"); | |
| if (i !== null) { promptEl.value = EXAMPLES[+i]; promptEl.focus(); } | |
| }); | |
| renderPreview(""); | |
| setStatus("", "idle"); | |
| </script> | |
| </body> | |
| </html> | |