import { LoggerWithoutDebug, Wllama } from "@wllama/wllama"; import type { ChatCompletionChunk, ChatCompletionMessage, ChatCompletionParams, ChatCompletionResponse, ResultTimings, } from "@wllama/wllama"; import wasmUrl from "@wllama/wllama/esm/wasm/wllama.wasm?url"; import "./style.css"; const DEFAULT_LLM_URL = "https://huggingface.co/ryanhlewis/gemma-4-E2B-it-qat-q4_0-gguf-webgpu/resolve/main/gemma-4-E2B_q4_0-it-00001-of-00005.gguf"; const DEFAULT_MMPROJ_URL = "https://huggingface.co/ryanhlewis/gemma-4-E2B-it-qat-q4_0-gguf-webgpu/resolve/main/gemma-4-E2B-it-mmproj.gguf"; const GOOGLE_SINGLE_GGUF = "https://huggingface.co/google/gemma-4-E2B-it-qat-q4_0-gguf/resolve/main/gemma-4-E2B_q4_0-it.gguf"; const TOTAL_DOWNLOAD_BYTES = 4_340_000_000; type Role = "user" | "assistant"; type ChatEntry = { role: Role; content: string; thinking?: string; thinkingOpen?: boolean; }; type StreamParts = { answer: string; thinking: string; replaceAnswer: boolean; replaceThinking: boolean; }; type ThinkParseState = { inThinking: boolean; tagBuffer: string; }; type LoadPhase = "idle" | "preparing" | "downloading" | "initializing" | "ready" | "error"; type LoadStats = { loaded: number; total: number; startedAt: number; phaseStartedAt: number; doneAt?: number; }; const params = new URLSearchParams(window.location.search); const root = document.querySelector("#app"); if (!root) throw new Error("#app missing"); const app = root; let wllama: Wllama | null = null; let modelLoaded = false; let loading = false; let loadPhase: LoadPhase = "idle"; let generating = false; let loadStats: LoadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt: 0, phaseStartedAt: 0 }; let chat: ChatEntry[] = []; let imageFile: File | null = null; let abortController: AbortController | null = null; let lastTimings: ResultTimings | undefined; let settingsOpen = false; const state = { llmUrl: params.get("modelUrl") || params.get("llmUrl") || DEFAULT_LLM_URL, mmprojUrl: params.get("mmprojUrl") || DEFAULT_MMPROJ_URL, context: Number(params.get("ctx") || 4096), threads: Number(params.get("threads") || 4), batch: Number(params.get("batch") || 512), gpuLayers: Number(params.get("gpuLayers") || 999), maxTokens: Number(params.get("maxTokens") || 384), temperature: Number(params.get("temperature") || 0.7), topP: Number(params.get("topP") || 0.95), thinking: params.get("thinking") === "enabled" ? "enabled" : "disabled", seed: Number(params.get("seed") || -1), prompt: params.get("prompt") || "Explain what makes Gemma 4 E2B useful for browser-local AI in three concise bullets.", }; function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; const units = ["B", "KiB", "MiB", "GiB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; } function formatSeconds(seconds: number): string { if (!Number.isFinite(seconds) || seconds < 0) return "0.0s"; return `${seconds.toFixed(1)}s`; } function elapsedLoadSeconds(): number { if (!loadStats.startedAt) return 0; const end = loadStats.doneAt || performance.now(); return (end - loadStats.startedAt) / 1000; } function elapsedPhaseSeconds(): number { if (!loadStats.phaseStartedAt) return 0; return (performance.now() - loadStats.phaseStartedAt) / 1000; } function setLoadPhase(phase: LoadPhase): void { if (loadPhase === phase) return; loadPhase = phase; loadStats.phaseStartedAt = performance.now(); } function loadPercent(): number { if (!loadStats.total) return 0; return Math.max(0, Math.min(100, (loadStats.loaded / loadStats.total) * 100)); } function statusLabel(): string { if (modelLoaded) return "ready"; if (!loading) return loadPhase === "error" ? "error" : "idle"; if (loadPhase === "downloading") return "downloading"; if (loadPhase === "initializing") return "initializing"; return "preparing"; } function loadTitle(): string { if (modelLoaded) return "Model ready"; if (loadPhase === "preparing") return "Preparing runtime"; if (loadPhase === "downloading") return "Downloading model assets"; if (loadPhase === "initializing") return "Loading model into WebGPU"; if (loadPhase === "error") return "Load failed"; return "Download model assets"; } function loadDetail(progress: number): string { if (modelLoaded) return `Ready in ${formatSeconds(elapsedLoadSeconds())}`; if (loading && loadPhase === "preparing") { return `Starting browser runtime | ${formatSeconds(elapsedPhaseSeconds())}`; } if (loading && loadPhase === "downloading") { return `${progress.toFixed(1)}% | ${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)} | ${formatBytes( loadStats.loaded / Math.max(elapsedLoadSeconds(), 0.1) )}/s`; } if (loading && loadPhase === "initializing") { return `Download complete | Initializing ${formatSeconds(elapsedPhaseSeconds())}`; } return "4.34 GB"; } function webgpuStatus(): string { if (!("gpu" in navigator)) return "unavailable"; return modelLoaded && wllama?.isSupportWebGPU() ? "ready" : "available"; } function iconSvg(name: "settings" | "newChat" | "chevronDown" | "chevronUp"): string { if (name === "settings") { return ` `; } if (name === "chevronUp") { return ` `; } if (name === "chevronDown") { return ` `; } return ` `; } function setLog(message: string): void { currentLog = message; const el = document.querySelector("#log"); if (el) el.textContent = message; } function appendLog(message: string): void { currentLog = `${currentLog ? `${currentLog}\n` : ""}${message}`; const el = document.querySelector("#log"); if (!el) return; el.textContent = currentLog; el.scrollTop = el.scrollHeight; } function renderMessage(item: ChatEntry, index: number): string { if (item.role === "user") { return `
${escapeHtml(item.content)}
`; } const thinking = item.thinking?.trim() || ""; const shouldShowThinking = state.thinking === "enabled" && thinking.length > 0; const arrow = item.thinkingOpen ? iconSvg("chevronUp") : iconSvg("chevronDown"); const thinkingBlock = shouldShowThinking ? `
${ item.thinkingOpen ? `
${escapeHtml(thinking)}
` : "" }
` : ""; const answer = item.content.trim() ? `
${escapeHtml(item.content)}
` : ""; const waiting = !answer && !thinkingBlock ? `
Waiting for response...
` : ""; return `
${thinkingBlock}${answer}${waiting}
`; } function render(): void { const progress = loadPercent(); const progressClass = loadPhase === "initializing" ? "progress indeterminate" : "progress"; const barWidth = loadPhase === "preparing" && loading ? 8 : loadPhase === "initializing" ? 100 : progress; app.innerHTML = `
G4

Gemma 4 WebGPU

GGUF chat running locally in your browser with Wllama and llama.cpp WebGPU.

${ settingsOpen ? `

Model source

` : "" }
Status${statusLabel()}
WebGPU${webgpuStatus()}
Prompt${lastTimings ? `${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "-"}
Generate${lastTimings ? `${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "-"}
${ chat.length ? `
${chat .map((item, index) => renderMessage(item, index)) .join("")}
` : `

Ask Gemma 4 locally

Download once, then chat from browser cache.

` }
${imageFile ? `` : ""} ${ generating ? `` : `` }
`; bindEvents(); requestAnimationFrame(() => { const messages = document.querySelector(".messages"); if (messages) messages.scrollTop = messages.scrollHeight; }); } let currentLog = "Ready."; function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttr(value: string): string { return escapeHtml(value).replaceAll("\n", " "); } function syncInputs(): void { state.prompt = getTextAreaValue("promptInput", state.prompt); state.llmUrl = getInputValue("llmUrl", state.llmUrl); state.mmprojUrl = getInputValue("mmprojUrl", state.mmprojUrl); state.context = getNumberValue("context", state.context); state.threads = getNumberValue("threads", state.threads); state.batch = getNumberValue("batch", state.batch); state.gpuLayers = getNumberValue("gpuLayers", state.gpuLayers); state.maxTokens = getNumberValue("maxTokens", state.maxTokens); state.temperature = getNumberValue("temperature", state.temperature); state.topP = getNumberValue("topP", state.topP); state.thinking = getInputValue("thinking", state.thinking) === "enabled" ? "enabled" : "disabled"; state.seed = getNumberValue("seed", state.seed); } function getInputValue(id: string, fallback: string): string { return document.querySelector(`#${id}`)?.value.trim() || fallback; } function getTextAreaValue(id: string, fallback: string): string { return document.querySelector(`#${id}`)?.value.trim() || fallback; } function getNumberValue(id: string, fallback: number): number { const value = Number(document.querySelector(`#${id}`)?.value); return Number.isFinite(value) ? value : fallback; } function bindEvents(): void { document.querySelector("#loadButton")?.addEventListener("click", () => { void loadModel(); }); document.querySelector("#chatForm")?.addEventListener("submit", (event) => { event.preventDefault(); void sendMessage(); }); document.querySelector("#stopButton")?.addEventListener("click", () => { abortController?.abort(); }); document.querySelector("#imageInput")?.addEventListener("change", (event) => { const files = (event.target as HTMLInputElement).files; imageFile = files?.[0] || null; render(); }); document.querySelector("#removeImageButton")?.addEventListener("click", () => { imageFile = null; render(); }); document.querySelector("#clearButton")?.addEventListener("click", () => { chat = []; lastTimings = undefined; render(); }); document.querySelector("#settingsButton")?.addEventListener("click", () => { syncInputs(); settingsOpen = !settingsOpen; render(); }); document.querySelector("#thinking")?.addEventListener("change", () => { syncInputs(); render(); }); document.querySelectorAll("[data-thinking-index]").forEach((button) => { button.addEventListener("click", () => { const index = Number(button.dataset.thinkingIndex); const entry = chat[index]; if (!entry || entry.role !== "assistant") return; entry.thinkingOpen = !entry.thinkingOpen; render(); }); }); document.querySelector("#googleSingleButton")?.addEventListener("click", () => { syncInputs(); state.llmUrl = GOOGLE_SINGLE_GGUF; modelLoaded = false; render(); appendLog("Switched to the official single GGUF URL. Browsers may reject files over 2 GB; split shards are recommended."); }); } async function loadModel(): Promise { if (loading || modelLoaded) return; syncInputs(); loading = true; const startedAt = performance.now(); loadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt, phaseStartedAt: startedAt }; setLoadPhase("preparing"); currentLog = "Preparing browser runtime..."; render(); try { wllama = new Wllama( { default: wasmUrl }, { logger: LoggerWithoutDebug, suppressNativeLog: true, parallelDownloads: 5, } ); wllama.setCompat(null); if (!wllama.isSupportWebGPU()) { appendLog("WebGPU is not available. Wllama may fall back or fail depending on this browser."); } appendLog(`Model source: ${state.llmUrl}`); await wllama.loadModelFromUrl( { url: state.llmUrl, mmprojUrl: state.mmprojUrl || undefined, }, { useCache: true, n_ctx: state.context, n_threads: state.threads, n_batch: state.batch, n_gpu_layers: state.gpuLayers, flash_attn: true, cache_type_k: "f16", cache_type_v: "f16", mmproj_offload: true, seed: state.seed >= 0 ? state.seed : Math.floor(Math.random() * 2_147_483_647), progressCallback: ({ loaded, total }) => { loadStats.loaded = loaded; loadStats.total = total || loadStats.total || TOTAL_DOWNLOAD_BYTES; if (loadStats.total && loadStats.loaded >= loadStats.total) { setLoadPhase("initializing"); currentLog = [ "Download complete.", `Initializing WebGPU model for ${formatSeconds(elapsedPhaseSeconds())}`, "This step loads weights and builds GPU resources.", ].join("\n"); } else { setLoadPhase("downloading"); currentLog = [ "Downloading model assets...", `${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)}`, `${formatBytes(loadStats.loaded / Math.max(elapsedLoadSeconds(), 0.1))}/s`, ].join("\n"); } render(); }, } ); loadStats.doneAt = performance.now(); modelLoaded = true; setLoadPhase("ready"); currentLog = [ `Loaded in ${formatSeconds(elapsedLoadSeconds())}`, `libllama ${Wllama.getLibllamaVersion()}`, `threads ${wllama.getNumThreads()}${wllama.isMultithread() ? " multi-thread" : " single-thread"}`, `modalities image=${safeSupport("image")} audio=${safeSupport("audio")}`, ].join("\n"); } catch (error) { setLoadPhase("error"); currentLog = `Load failed:\n${errorToText(error)}`; modelLoaded = false; void wllama?.exit().catch(() => undefined); wllama = null; } finally { loading = false; render(); } } function safeSupport(kind: "image" | "audio"): string { try { return wllama?.supportInputModality(kind) ? "yes" : "no"; } catch { return "unknown"; } } async function clearCache(): Promise { if (!wllama) { wllama = new Wllama({ default: wasmUrl }, { logger: LoggerWithoutDebug, suppressNativeLog: true }); } await wllama.cacheManager.clear(); modelLoaded = false; currentLog = "Browser model cache cleared."; render(); } async function sendMessage(): Promise { if (!wllama || !modelLoaded || generating) return; syncInputs(); if (!state.prompt) return; const userMessage = state.prompt; const imageData = imageFile ? await imageFile.arrayBuffer() : null; const content = imageData ? [ { type: "text" as const, text: userMessage }, { type: "image" as const, data: imageData }, ] : userMessage; const history: ChatCompletionMessage[] = chat .filter((entry) => entry.content.trim().length > 0) .map((entry) => ({ role: entry.role, content: entry.content, })); chat.push({ role: "user", content: imageFile ? `${userMessage}\n[image: ${imageFile.name}]` : userMessage }); const assistantIndex = chat.length; chat.push({ role: "assistant", content: "" }); state.prompt = ""; generating = true; abortController = new AbortController(); lastTimings = undefined; const startedAt = performance.now(); const thinkParser: ThinkParseState = { inThinking: false, tagBuffer: "" }; render(); try { const userTurn = { role: "user", content, } as ChatCompletionMessage; const thinkingInstruction = state.thinking === "disabled" ? ([ { role: "system", content: "Answer directly. Do not include thinking, reasoning traces, or sections.", }, ] as ChatCompletionMessage[]) : []; const request: ChatCompletionParams = { messages: [ ...thinkingInstruction, ...history, userTurn, ], max_tokens: state.maxTokens, temperature: state.temperature, top_p: state.topP, seed: state.seed >= 0 ? state.seed : undefined, cache_prompt: true, timings_per_token: true, chat_template_kwargs: { enable_thinking: state.thinking === "enabled", }, }; await wllama.createChatCompletion({ ...request, stream: true, abortSignal: abortController.signal, onData: (chunk: ChatCompletionChunk) => { const delta = extractStreamParts(chunk, thinkParser); lastTimings = chunk.timings || lastTimings; const assistant = chat[assistantIndex]; if (assistant?.role === "assistant") { if (delta.answer) { assistant.content = delta.replaceAnswer ? delta.answer : assistant.content + delta.answer; } if (delta.thinking) { assistant.thinking = delta.replaceThinking ? delta.thinking : `${assistant.thinking || ""}${delta.thinking}`; } } const elapsed = (performance.now() - startedAt) / 1000; currentLog = [ `Generating ${formatSeconds(elapsed)}`, assistant?.content ? `Answer ${assistant.content.length} chars` : "Waiting for answer text...", state.thinking === "enabled" && assistant?.thinking ? `Thinking ${assistant.thinking.length} chars` : "", lastTimings ? `Prompt ${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "", lastTimings ? `Output ${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "", ] .filter(Boolean) .join("\n"); render(); }, }); const assistant = chat[assistantIndex]; const flushed = flushThinkParser(thinkParser); if (assistant?.role === "assistant") { if (flushed.answer) assistant.content += flushed.answer; if (flushed.thinking) assistant.thinking = `${assistant.thinking || ""}${flushed.thinking}`; } if (assistant?.role === "assistant" && !assistant.content.trim()) { currentLog = "Stream returned timings but no visible text. Fetching final response..."; render(); const response = await wllama.createChatCompletion({ ...request, stream: false, abortSignal: abortController.signal, }); const finalParts = extractFinalParts(response); assistant.content = finalParts.answer; assistant.thinking = finalParts.thinking || assistant.thinking; } const elapsed = (performance.now() - startedAt) / 1000; currentLog = formatDoneLog(elapsed, lastTimings); } catch (error) { const last = chat[chat.length - 1]; if (last?.role === "assistant" && !last.content) { last.content = `Error: ${errorToText(error)}`; } currentLog = `Generation stopped:\n${errorToText(error)}`; } finally { generating = false; abortController = null; render(); } } function formatDoneLog(elapsed: number, timings?: ResultTimings): string { return [ `Done in ${formatSeconds(elapsed)}`, timings ? `Prompt ${timings.prompt_n} tokens at ${timings.prompt_per_second.toFixed(1)} tok/s` : "", timings ? `Generated ${timings.predicted_n} tokens at ${timings.predicted_per_second.toFixed(1)} tok/s` : "", ] .filter(Boolean) .join("\n"); } function errorToText(error: unknown): string { if (error instanceof Error) return error.stack || error.message; return String(error); } function textFromValue(value: unknown): string { if (typeof value === "string") return value; if (value == null) return ""; if (Array.isArray(value)) { return value .map((item) => { if (typeof item === "string") return item; if (item && typeof item === "object") { const typed = item as { text?: unknown; content?: unknown }; return textFromValue(typed.text || typed.content); } return ""; }) .join(""); } return ""; } function splitTaggedThinking(text: string, state: ThinkParseState): { answer: string; thinking: string } { let answer = ""; let thinking = ""; const tags = ["", "", "", ""]; const pushText = (value: string) => { if (state.inThinking) { thinking += value; } else { answer += value; } }; for (const char of text) { if (state.tagBuffer) { state.tagBuffer += char; const lower = state.tagBuffer.toLowerCase(); if (lower === "" || lower === "") { state.inThinking = true; state.tagBuffer = ""; continue; } if (lower === "" || lower === "") { state.inThinking = false; state.tagBuffer = ""; continue; } if (tags.some((tag) => tag.startsWith(lower))) { continue; } pushText(state.tagBuffer); state.tagBuffer = ""; continue; } if (char === "<") { state.tagBuffer = char; continue; } pushText(char); } return { answer, thinking }; } function flushThinkParser(state: ThinkParseState): { answer: string; thinking: string } { if (!state.tagBuffer) return { answer: "", thinking: "" }; const pending = state.tagBuffer; state.tagBuffer = ""; return state.inThinking ? { answer: "", thinking: pending } : { answer: pending, thinking: "" }; } function splitCompleteTaggedThinking(text: string): { answer: string; thinking: string } { const parser: ThinkParseState = { inThinking: false, tagBuffer: "" }; const parts = splitTaggedThinking(text, parser); const flushed = flushThinkParser(parser); return { answer: parts.answer + flushed.answer, thinking: parts.thinking + flushed.thinking, }; } function extractStreamParts(chunk: unknown, state: ThinkParseState): StreamParts { const data = chunk as Record; const choices = Array.isArray(data.choices) ? (data.choices as Array>) : []; const choice = choices[0] || {}; const delta = (choice.delta && typeof choice.delta === "object" ? choice.delta : {}) as Record; const message = (choice.message && typeof choice.message === "object" ? choice.message : {}) as Record; const token = (data.token && typeof data.token === "object" ? data.token : {}) as Record; const explicitThinking = textFromValue(delta.reasoning_content) || textFromValue(delta.thinking) || textFromValue(message.reasoning_content) || textFromValue(message.thinking) || textFromValue(data.reasoning_content) || textFromValue(data.thinking); const appendText = textFromValue(delta.content) || textFromValue(delta.text) || textFromValue(choice.text) || textFromValue(data.content) || textFromValue(data.response) || textFromValue(data.text) || textFromValue(token.text); const replacementText = textFromValue(message.content) || textFromValue(data.message); const appendParts = splitTaggedThinking(appendText, state); const replacementParts = replacementText ? splitCompleteTaggedThinking(replacementText) : { answer: "", thinking: "" }; return { answer: appendParts.answer || replacementParts.answer, thinking: explicitThinking || appendParts.thinking || replacementParts.thinking, replaceAnswer: Boolean(replacementParts.answer), replaceThinking: Boolean(explicitThinking || replacementParts.thinking), }; } function extractFinalParts(response: ChatCompletionResponse): { answer: string; thinking: string } { const message = response.choices?.[0]?.message as unknown as Record | undefined; const content = textFromValue(message?.content); const explicitThinking = textFromValue(message?.reasoning_content) || textFromValue(message?.thinking); const parsed = splitCompleteTaggedThinking(content); return { answer: parsed.answer, thinking: explicitThinking || parsed.thinking, }; } render();