diff --git "a/index.html" "b/index.html" --- "a/index.html" +++ "b/index.html" @@ -1,1619 +1,1681 @@ - - - - - - -Gemma 4 E2B · WebGPU - - - - - - - - - - - -
-
-
- -
-
-
On-device · WebGPU · agentic kernel optimization
-

Gemma 4 in your browser.
Kernels written by Fable 5.

-
-
-

- Gemma 4 E2B (QAT Mobile) — a powerful open-source model — runs - fully on-device with WebGPU. Weights cache locally after the first load, and nothing you - type ever leaves your machine. -

-
-
2.3BEffective params
-
128KContext window
-
~250tok/s · M4 Max
-
100%On-device
-
-
-
-
- - Model card -
-
- WebGPU kernels 100% written & optimized by Fable 5 - Tuned for Apple M4 Max · experimental -
-
-
-
- -
- Chat below -
-
-
-
- - -
-
-
- - - Gemma 4 · E2B - -
- - Not loaded -
-
- - - -
-
-
-
- -
-
-
-

What's on your mind today?

-

Model runs entirely on your device.

-
- - - -
-
-
-
- - -
- - - - - - - - - - - - + + + + + +
+
+
+ +
+
+
On-device · WebGPU · agentic kernel optimization
+

Gemma 4 in your browser.
Kernels written by Fable 5.

+
+
+

+ Gemma 4 E2B (QAT Mobile) — a powerful open-source model — runs + fully on-device with WebGPU. Weights cache locally after the first load, and nothing you + type ever leaves your machine. +

+
+
2.3BEffective params
+
128KContext window
+
~250tok/s · M4 Max
+
100%On-device
+
+
+
+
+ + Model card +
+
+ WebGPU kernels 100% written & optimized by Fable 5 + Tuned for Apple M4 Max · experimental +
+
+
+
+ +
+ Chat below +
+
+
+
+ + +
+
+
+ + + Gemma 4 · E2B + +
+ + Not loaded +
+
+ + + +
+
+
+
+ +
+
+
+

What's on your mind today?

+

Model runs entirely on your device.

+
+ + + +
+
+
+
+ + +
+ + + + + + + + + + + + - - + function updateLoadProgress(event) { + if (event.status !== "weights") { + setStatus("loading", labelFor(event.status)); + setPhaseProgress(event.status, event.fraction); + return; + } + const kind = event.kind ?? inferProgressKind(event); + const fraction = finiteNumber(event.fraction) ? clamp(event.fraction, 0, 1) : null; + // Drive the bar off the BYTE download only. The "tensors" stream counts materialized tensors, + // which races far ahead of the download (the many small tensors finish while the big embedding + // weights are still downloading by size), so it would leap the bar past the real progress. + // Tensor events still update the status text below. + if (kind !== "tensors") setPhaseProgress("weights", fraction); + setStatus("loading", formatWeightProgress(event, fraction)); + } + + // Map each load phase onto an increasing slice of the bar (weights — the byte download — owns the + // bulk). setProgressFraction's monotonic guard keeps it from ever dipping. + function setPhaseProgress(status, frac) { + const [lo, hi] = status === "weights" + ? [0.04, 1.0] + : ({ init: [0, 0.02], tokenizer: [0.02, 0.04], ready: [1, 1] }[status] ?? [0, 1]); + const f = finiteNumber(frac) ? clamp(frac, 0, 1) : 0; + setProgressFraction(lo + (hi - lo) * f); + } + + function formatWeightProgress(event, fraction) { + const kind = event.kind ?? inferProgressKind(event); + const pct = fraction === null ? "" : ` (${Math.round(fraction * 100)}%)`; + const loaded = finiteNumber(event.loaded) ? event.loaded : null; + const total = finiteNumber(event.total) ? event.total : null; + if (kind === "bytes") { + const verb = event.fromCache ? "Loading cached weights" : "Downloading weights"; + if (loaded !== null && total !== null) return `${verb}: ${formatBytes(loaded)} / ${formatBytes(total)}${pct}`; + if (total !== null) return `${verb}: ${formatBytes(total)} total`; + return `${escapeHtml(event.message || verb)}…`; + } + if (loaded !== null && total !== null) { + const label = event.message ? ` (${escapeHtml(event.message)})` : ""; + return `Preparing GPU weights: ${formatInteger(loaded)} / ${formatInteger(total)} tensors${pct}${label}`; + } + return event.message ? `Preparing GPU weights: ${escapeHtml(event.message)}` : "Preparing GPU weights…"; + } + + function inferProgressKind(event) { + if (event.kind === "bytes" || event.kind === "tensors") return event.kind; + if (finiteNumber(event.total) && event.total > 1_000_000) return "bytes"; + return "tensors"; + } + + // Record the latest (monotonic) target and let a single rAF loop ease the bar toward it. This + // coalesces bursts of byte-progress events into one write per frame and — unlike a fixed CSS + // width transition — never lags behind fast progress (a big remaining gap is closed quickly). + function setProgressFraction(value) { + if (!finiteNumber(value)) return; + targetProgress = Math.max(clamp(value, 0, 1), targetProgress); // monotonic — never hops backwards + if (!progressRaf) progressRaf = requestAnimationFrame(stepProgressBar); + } + + function stepProgressBar() { + const gap = targetProgress - shownProgress; + shownProgress += gap < 0.0015 ? gap : gap * 0.3; // ~30% of the gap per frame; snap when nearly there + barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; + progressRaf = shownProgress < targetProgress ? requestAnimationFrame(stepProgressBar) : 0; + } + + function setProgressImmediate(value) { + if (progressRaf) { cancelAnimationFrame(progressRaf); progressRaf = 0; } + targetProgress = shownProgress = clamp(value, 0, 1); + barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; + } + + function enableChat() { + isLoading = false; + input.disabled = false; + input.placeholder = "Ask anything…"; + clearBtn.disabled = false; + kernelsBtn.hidden = false; + setSeedButtonsEnabled(true); + refreshSend(); + input.focus(); + } + + // ---- Kernels viewer: the real rendered WGSL the model compiled on this GPU ---- + function openKernels() { + if (!model) return; + kernels = model.runtime.getRenderedShaders?.() ?? []; + const list = $("kxList"); + list.replaceChildren(); + $("kxSub").textContent = kernels.length + ? `${kernels.length} WGSL compute shaders · written & optimized by Fable 5 · running on your GPU` + : "No kernels compiled yet — send a message first."; + kernels.forEach((k, i) => { + const item = document.createElement("button"); + item.className = "kx-item"; + item.type = "button"; + item.textContent = k.name; + item.addEventListener("click", () => selectKernel(i)); + list.appendChild(item); + }); + // Open on the explanation, with no kernel selected — the source shows only once one is clicked. + [...list.children].forEach((el) => el.classList.remove("active")); + $("kxSource").hidden = true; + $("kxIntro").hidden = false; + kxCopySource = ""; + kernelsOverlay.hidden = false; + document.body.classList.add("kx-locked"); + list.scrollTop = 0; + requestAnimationFrame(updateListFade); + } + + // Toggle the bottom fade on the kernel list: shown while there is more to scroll, hidden at the end. + function updateListFade() { + const list = $("kxList"); + const atEnd = list.scrollHeight <= list.clientHeight + 4 + || list.scrollTop >= list.scrollHeight - list.clientHeight - 4; + list.parentElement.classList.toggle("at-end", atEnd); + } + + function selectKernel(i) { + const k = kernels[i]; + if (!k) return; + $("kxIntro").hidden = true; // swap the explanation out for the source + $("kxSource").hidden = false; + [...$("kxList").children].forEach((el, j) => el.classList.toggle("active", j === i)); + $("kxName").textContent = k.name; + $("kxLines").textContent = `${k.source.split("\n").length} lines`; + $("kxCode").innerHTML = highlightWgsl(k.source); + $("kxCode").parentElement.scrollTop = 0; + kxCopySource = k.source; + } + + function closeKernels() { + kernelsOverlay.hidden = true; + document.body.classList.remove("kx-locked"); + } + + async function copyKernel() { + if (!kxCopySource) return; + try { + await navigator.clipboard.writeText(kxCopySource); + const btn = $("kxCopy"); + btn.textContent = "Copied"; + setTimeout(() => { btn.textContent = "Copy"; }, 1200); + } catch { /* clipboard blocked — ignore */ } + } + + const WGSL_KEYWORDS = new Set(["fn","let","var","const","const_assert","struct","if","else","for","loop","return","break","continue","switch","case","default","while","override","enable","requires","discard","alias","true","false","workgroup","storage","uniform","function","private","read","write","read_write","bitcast"]); + const WGSL_TYPES = new Set(["u32","i32","f32","f16","bool","vec2","vec3","vec4","mat2x2","mat3x3","mat4x4","mat2x3","mat3x2","mat2x4","mat4x2","mat3x4","mat4x3","array","atomic","ptr","sampler"]); + const WGSL_TOKEN = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(@[A-Za-z_]\w*)|([A-Za-z_]\w*)|(\d[\w.]*)|(\s+)|([\s\S])/g; + + function highlightWgsl(src) { + let out = ""; + WGSL_TOKEN.lastIndex = 0; + let m; + while ((m = WGSL_TOKEN.exec(src))) { + const [tok, comment, attr, ident, num, ws] = m; + if (comment) out += `${escapeHtml(comment)}`; + else if (attr) out += `${escapeHtml(attr)}`; + else if (ident) { + const cls = WGSL_KEYWORDS.has(ident) ? "k-kw" : WGSL_TYPES.has(ident) ? "k-ty" : null; + out += cls ? `${ident}` : escapeHtml(ident); + } + else if (num) out += `${escapeHtml(num)}`; + else if (ws) out += ws; + else out += escapeHtml(tok); + } + return out; + } + + async function send() { + const text = input.value.trim(); + if (!text || !model || isGenerating) return; + + removeWelcome(); + input.value = ""; + autoGrow(); refreshSend(); + + appendUserMessage(text); + messages.push({ role: "user", content: text }); + + const assistant = appendAssistantMessage(); + const bubble = assistant.querySelector(".bubble"); + bubble.innerHTML = ''; + scrollDown(); + + setGenerating(true); + abortController = new AbortController(); + + let reply = ""; + let startedAt = 0, firstTokenAt = 0, endedAt = 0, generatedTokens = 0; + + try { + const stream = model.generate(messages, { maxNewTokens: 4096, signal: abortController.signal }); + startedAt = performance.now(); + for await (const { text: full } of stream) { + const now = performance.now(); + if (!firstTokenAt) firstTokenAt = now; + generatedTokens++; + reply = full; + scheduleAssistantRender(bubble, reply); + updateLiveStat({ startedAt, firstTokenAt, now, generatedTokens }); + } + } catch (error) { + console.error(error); + if (!reply) reply = `_Stopped: ${String(error?.message ?? error)}_`; + } finally { + endedAt = performance.now(); + renderState = null; // cancel any pending coalesced render; show the final reply now + renderAssistant(bubble, reply, false); + appendMeta(assistant, { startedAt, firstTokenAt, endedAt, generatedTokens }); + scrollDown(); // the meta line is added after the reply — keep it in view + messages.push({ role: "assistant", content: reply }); + setGenerating(false); + liveStat.textContent = ""; + abortController = null; + input.focus(); + } + } + + function setGenerating(on) { + isGenerating = on; + input.disabled = on; + clearBtn.disabled = on; + sendBtn.style.display = on ? "none" : ""; + stopBtn.style.display = on ? "grid" : "none"; + setStatus(on ? "busy" : "ready", on ? "Generating…" : "Ready · on-device"); + hint.textContent = on ? "Generating on-device…" : "Runs fully on-device — nothing leaves your machine"; + refreshSend(); + } + + function updateLiveStat({ startedAt, firstTokenAt, now, generatedTokens }) { + if (generatedTokens <= 1) { liveStat.textContent = `TTFT ${(firstTokenAt - startedAt).toFixed(0)} ms`; return; } + const decodeSeconds = Math.max((now - firstTokenAt) / 1000, 1e-9); + const tps = (generatedTokens - 1) / decodeSeconds; + liveStat.textContent = `${tps.toFixed(0)} tok/s`; + } + + function clearChat() { + messages = []; + model?.reset(); + thread.replaceChildren(createWelcome()); + clearBtn.disabled = !model; + setSeedButtonsEnabled(Boolean(model)); + input.focus(); + } + + function appendUserMessage(text) { + const msg = document.createElement("div"); + msg.className = "msg user"; + msg.appendChild(roleLabel("You")); + const bubble = document.createElement("div"); + bubble.className = "bubble user"; + bubble.textContent = text; + msg.appendChild(bubble); + thread.appendChild(msg); + scrollDown(); + return msg; + } + + function appendAssistantMessage() { + const msg = document.createElement("div"); + msg.className = "msg assistant"; + msg.appendChild(roleLabel("Gemma")); + const bubble = document.createElement("div"); + bubble.className = "bubble assistant"; + msg.appendChild(bubble); + thread.appendChild(msg); + return msg; + } + + function roleLabel(text) { + const label = document.createElement("div"); + label.className = "role"; + label.textContent = text; + return label; + } + + function appendMeta(msg, timing) { + if (timing.generatedTokens <= 0) return; + const stats = generationStats(timing); + const meta = document.createElement("div"); + meta.className = "meta"; + const parts = [`${timing.generatedTokens} tok`, `TTFT ${stats.ttftMs.toFixed(0)} ms`]; + if (stats.decodeTokensPerSecond > 0) parts.push(`${stats.decodeTokensPerSecond.toFixed(1)} tok/s`); + meta.textContent = parts.join(" · "); + msg.appendChild(meta); + } + + function generationStats({ startedAt, firstTokenAt, endedAt, generatedTokens }) { + if (generatedTokens <= 0 || !startedAt || !firstTokenAt || !endedAt) return { ttftMs: 0, decodeTokensPerSecond: 0 }; + const decodeTokens = Math.max(generatedTokens - 1, 0); + const decodeSeconds = Math.max((endedAt - firstTokenAt) / 1000, 1e-9); + return { ttftMs: firstTokenAt - startedAt, decodeTokensPerSecond: decodeTokens > 0 ? decodeTokens / decodeSeconds : 0 }; + } + + // Coalesce streamed renders to one per animation frame — marked re-parses the full reply each + // call, so parsing per token (≈250/s) would tax decode; rAF caps it to the display rate. + function scheduleAssistantRender(bubble, raw) { + renderState = { bubble, raw }; + if (renderScheduled) return; + renderScheduled = true; + requestAnimationFrame(() => { + renderScheduled = false; + if (!renderState) return; + renderAssistant(renderState.bubble, renderState.raw, true); + scrollDown(); + }); + } + + function renderAssistant(bubble, raw, withCaret) { + if (marked) { + try { + bubble.innerHTML = sanitizeHtml(marked.parse(raw || "")); + if (withCaret) appendCaret(bubble); + return; + } catch { /* fall through to the simple renderer */ } + } + const safe = escapeHtml(raw || ""); + const paragraphs = safe.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean); + if (paragraphs.length === 0) { bubble.textContent = ""; return; } + bubble.innerHTML = paragraphs.map((p) => `

${formatInline(p).replace(/\n/g, "
")}

`).join(""); + if (withCaret) appendCaret(bubble); + } + + function appendCaret(bubble) { + const caret = document.createElement("span"); + caret.className = "caret"; + (bubble.querySelector("p:last-of-type") || bubble).appendChild(caret); + } + + // Strip anything executable from the model's markdown before inserting it (defence in depth — a + // local model is the only content source, but raw + +