| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Gemma 3 1B Chat</title> |
| <style> |
| :root { |
| color-scheme: light; |
| --bg: #f6f3ee; |
| --panel: #ffffff; |
| --line: #e7dfd4; |
| --text: #1f1a16; |
| --muted: #6d6256; |
| --accent: #b35a2b; |
| --accent-soft: #f7eee8; |
| --user: #f3ede5; |
| --assistant: #fcfaf7; |
| --error: #a53f2b; |
| --shadow: 0 16px 48px rgba(36, 24, 15, 0.08); |
| --radius: 22px; |
| --radius-sm: 16px; |
| --radius-pill: 999px; |
| --sans: "Avenir Next", "Segoe UI", sans-serif; |
| --mono: "SFMono-Regular", Consolas, monospace; |
| } |
| |
| * { |
| box-sizing: border-box; |
| } |
| |
| body { |
| margin: 0; |
| min-height: 100vh; |
| font-family: var(--sans); |
| background: linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%); |
| color: var(--text); |
| display: grid; |
| place-items: center; |
| padding: 20px; |
| } |
| |
| .app { |
| width: min(760px, 100%); |
| height: min(840px, calc(100vh - 40px)); |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| box-shadow: var(--shadow); |
| display: grid; |
| grid-template-rows: auto 1fr auto; |
| overflow: hidden; |
| } |
| |
| .header { |
| padding: 18px 20px; |
| border-bottom: 1px solid var(--line); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 16px; |
| } |
| |
| .title { |
| margin: 0; |
| font-size: 1rem; |
| font-weight: 700; |
| } |
| |
| .subtitle { |
| margin: 0; |
| color: var(--muted); |
| font-size: 0.92rem; |
| text-align: right; |
| } |
| |
| .spinner { |
| width: 18px; |
| height: 18px; |
| border: 2px solid rgba(179, 90, 43, 0.2); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| flex: 0 0 auto; |
| } |
| |
| .spinner.hidden { |
| display: none; |
| } |
| |
| .chat { |
| padding: 18px; |
| overflow: auto; |
| background: #fcfbf8; |
| display: grid; |
| align-content: start; |
| gap: 12px; |
| position: relative; |
| } |
| |
| .message { |
| max-width: 85%; |
| padding: 14px 16px; |
| border-radius: 18px; |
| line-height: 1.55; |
| white-space: pre-wrap; |
| border: 1px solid var(--line); |
| } |
| |
| .message.user { |
| margin-left: auto; |
| background: var(--user); |
| } |
| |
| .message.assistant { |
| background: var(--assistant); |
| } |
| |
| .message.placeholder { |
| max-width: 100%; |
| color: var(--muted); |
| background: #fffdfb; |
| text-align: left; |
| padding: 18px; |
| } |
| |
| .message.placeholder strong { |
| display: block; |
| margin-bottom: 10px; |
| color: var(--text); |
| } |
| |
| .examples { |
| display: grid; |
| gap: 10px; |
| } |
| |
| .example { |
| width: 100%; |
| text-align: left; |
| min-width: 0; |
| padding: 12px 14px; |
| border-radius: 14px; |
| border: 1px solid var(--line); |
| background: #faf6f1; |
| color: var(--text); |
| font-weight: 500; |
| } |
| |
| .example:hover:not(:disabled) { |
| background: var(--accent-soft); |
| } |
| |
| .center-state { |
| position: absolute; |
| inset: 0; |
| display: grid; |
| place-items: center; |
| padding: 24px; |
| text-align: center; |
| color: var(--muted); |
| } |
| |
| .center-card { |
| display: grid; |
| justify-items: center; |
| gap: 14px; |
| } |
| |
| .center-card .spinner { |
| width: 28px; |
| height: 28px; |
| border-width: 3px; |
| } |
| |
| .composer { |
| padding: 16px; |
| border-top: 1px solid var(--line); |
| background: var(--panel); |
| display: grid; |
| gap: 12px; |
| } |
| |
| .toolbar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| } |
| |
| .metrics { |
| display: flex; |
| justify-content: flex-end; |
| color: var(--muted); |
| font-size: 0.82rem; |
| font-family: var(--mono); |
| } |
| |
| .reset { |
| border: 1px solid var(--line); |
| background: #fffdfb; |
| color: var(--muted); |
| padding: 10px 14px; |
| min-width: 0; |
| } |
| |
| .input-row { |
| position: relative; |
| } |
| |
| textarea { |
| width: 100%; |
| min-height: 64px; |
| max-height: 180px; |
| resize: vertical; |
| border: 1px solid var(--line); |
| border-radius: var(--radius-sm); |
| padding: 16px 110px 16px 16px; |
| font: inherit; |
| color: var(--text); |
| background: #fffdfb; |
| outline: none; |
| } |
| |
| textarea:focus { |
| border-color: #d8b49f; |
| box-shadow: 0 0 0 4px rgba(179, 90, 43, 0.08); |
| } |
| |
| button { |
| appearance: none; |
| border: 0; |
| border-radius: var(--radius-pill); |
| padding: 12px 16px; |
| background: var(--accent); |
| color: white; |
| font: inherit; |
| font-weight: 400; |
| cursor: pointer; |
| min-width: 84px; |
| } |
| |
| .send { |
| position: absolute; |
| right: 10px; |
| top: 50%; |
| transform: translateY(-50%); |
| z-index: 1; |
| width: 44px; |
| height: 44px; |
| min-width: 44px; |
| padding: 0; |
| display: grid; |
| place-items: center; |
| border-radius: 14px; |
| } |
| |
| .send svg { |
| width: 18px; |
| height: 18px; |
| fill: currentColor; |
| } |
| |
| .send.stop { |
| background: #8b4a28; |
| } |
| |
| button:disabled { |
| cursor: not-allowed; |
| opacity: 0.6; |
| } |
| |
| .error { |
| color: var(--error); |
| } |
| |
| .cursor { |
| display: inline-block; |
| width: 0.75ch; |
| color: var(--accent); |
| animation: blink 1s step-end infinite; |
| } |
| |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| |
| @keyframes blink { |
| 50% { |
| opacity: 0; |
| } |
| } |
| |
| @media (max-width: 720px) { |
| .app { |
| height: calc(100vh - 20px); |
| } |
| |
| .header, |
| .toolbar { |
| align-items: start; |
| flex-direction: column; |
| } |
| |
| .subtitle { |
| text-align: left; |
| } |
| |
| .message { |
| max-width: 92%; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <main class="app"> |
| <header class="header"> |
| <h1 class="title">Gemma 3 1B Chat</h1> |
| <p class="subtitle">Powered by Transformers.js v4 (preview)</p> |
| </header> |
|
|
| <section id="chat" class="chat"> |
| <div id="loadingState" class="center-state"> |
| <div class="center-card"> |
| <span class="spinner"></span> |
| <div id="statusText">Loading model...</div> |
| </div> |
| </div> |
| </section> |
|
|
| <section class="composer"> |
| <div class="toolbar"> |
| <button id="resetButton" class="reset" type="button" disabled> |
| Reset |
| </button> |
| <div class="metrics" id="metrics">tokens/sec: -</div> |
| </div> |
| <div class="input-row"> |
| <textarea |
| id="prompt" |
| placeholder="Enter text here" |
| disabled |
| ></textarea> |
| <button |
| id="sendButton" |
| class="send" |
| type="button" |
| disabled |
| aria-label="Send message" |
| title="Send message" |
| ></button> |
| </div> |
| </section> |
| </main> |
|
|
| <script type="module"> |
| import { |
| pipeline, |
| TextStreamer, |
| InterruptableStoppingCriteria, |
| } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0-next.7"; |
| |
| const MODEL_ID = "onnx-community/gemma-3-1b-it-ONNX"; |
| const DTYPE = "q4"; |
| const REVISION = "main"; |
| const MAX_NEW_TOKENS = 256; |
| const SYSTEM_PROMPT = |
| "You are a helpful assistant who responds clearly and concisely."; |
| |
| const chatEl = document.getElementById("chat"); |
| const promptEl = document.getElementById("prompt"); |
| const sendButton = document.getElementById("sendButton"); |
| const resetButton = document.getElementById("resetButton"); |
| const statusTextEl = document.getElementById("statusText"); |
| const metricsEl = document.getElementById("metrics"); |
| const loadingStateEl = document.getElementById("loadingState"); |
| const SEND_ICON = ` |
| <svg viewBox="0 0 24 24" aria-hidden="true"> |
| <path d="M3.4 20.6 21 13 3.4 5.4l.1 5.8 11 1.8-11 1.8z"></path> |
| </svg> |
| `; |
| const STOP_ICON = ` |
| <svg viewBox="0 0 24 24" aria-hidden="true"> |
| <path d="M7 7h10v10H7z"></path> |
| </svg> |
| `; |
| |
| let generator = null; |
| let isGenerating = false; |
| let loadFailed = false; |
| const stoppingCriteria = new InterruptableStoppingCriteria(); |
| const conversation = [{ role: "system", content: SYSTEM_PROMPT }]; |
| const examplePrompts = [ |
| "What is the capital of France?", |
| "Explain photosynthesis in one sentence.", |
| "Write a poem about the ocean.", |
| ]; |
| |
| function setStatus(message, { error = false } = {}) { |
| statusTextEl.textContent = message; |
| statusTextEl.classList.toggle("error", error); |
| } |
| |
| function setMetrics(text) { |
| metricsEl.textContent = text; |
| } |
| |
| function scrollChatToBottom() { |
| chatEl.scrollTop = chatEl.scrollHeight; |
| } |
| |
| function removePlaceholder() { |
| const placeholder = chatEl.querySelector(".placeholder"); |
| if (placeholder) { |
| placeholder.remove(); |
| } |
| } |
| |
| function clearCenterState() { |
| if (loadingStateEl) { |
| loadingStateEl.remove(); |
| } |
| } |
| |
| function showExamples() { |
| clearCenterState(); |
| removePlaceholder(); |
| |
| if (chatEl.querySelector(".examples")) { |
| return; |
| } |
| |
| const card = document.createElement("div"); |
| card.className = "message assistant placeholder"; |
| card.innerHTML = "<strong>Try one of these</strong>"; |
| |
| const list = document.createElement("div"); |
| list.className = "examples"; |
| |
| for (const prompt of examplePrompts) { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "example"; |
| button.textContent = prompt; |
| button.addEventListener("click", () => { |
| promptEl.value = prompt; |
| sendMessage(prompt); |
| }); |
| list.appendChild(button); |
| } |
| |
| card.appendChild(list); |
| chatEl.appendChild(card); |
| } |
| |
| function addMessage(role, text = "") { |
| removePlaceholder(); |
| const node = document.createElement("div"); |
| node.className = `message ${role}`; |
| node.textContent = text; |
| chatEl.appendChild(node); |
| scrollChatToBottom(); |
| return node; |
| } |
| |
| function setStreamingMessage(node, text) { |
| node.textContent = text; |
| const cursor = document.createElement("span"); |
| cursor.className = "cursor"; |
| cursor.textContent = "▋"; |
| node.appendChild(cursor); |
| scrollChatToBottom(); |
| } |
| |
| function updateComposer() { |
| const ready = Boolean(generator) && !loadFailed; |
| promptEl.disabled = !ready || isGenerating; |
| sendButton.disabled = !ready; |
| resetButton.disabled = !ready || isGenerating; |
| sendButton.classList.toggle("stop", isGenerating); |
| sendButton.innerHTML = isGenerating ? STOP_ICON : SEND_ICON; |
| sendButton.setAttribute( |
| "aria-label", |
| isGenerating ? "Stop generation" : "Send message", |
| ); |
| sendButton.setAttribute( |
| "title", |
| isGenerating ? "Stop generation" : "Send message", |
| ); |
| } |
| |
| function resetChat() { |
| if (isGenerating) { |
| return; |
| } |
| |
| conversation.length = 1; |
| chatEl.innerHTML = ""; |
| showExamples(); |
| setStatus("Ready"); |
| setMetrics("tokens/sec: -"); |
| promptEl.value = ""; |
| promptEl.focus(); |
| } |
| |
| async function loadModel() { |
| if (!window.isSecureContext) { |
| throw new Error( |
| "WebGPU requires a secure context like https:// or localhost.", |
| ); |
| } |
| |
| if (!("gpu" in navigator)) { |
| throw new Error("This browser does not support WebGPU."); |
| } |
| |
| setStatus("Loading model..."); |
| setMetrics("tokens/sec: -"); |
| |
| const startedAt = performance.now(); |
| generator = await pipeline("text-generation", MODEL_ID, { |
| dtype: DTYPE, |
| revision: REVISION, |
| device: "webgpu", |
| progress_callback: (p) => { |
| if (p.status !== "progress_total") return; |
| const percent = Math.round(p.progress); |
| setStatus(`Downloading model... ${percent}%`); |
| }, |
| }); |
| |
| const seconds = ((performance.now() - startedAt) / 1000).toFixed(1); |
| setStatus(`Ready in ${seconds}s`); |
| setMetrics("tokens/sec: -"); |
| showExamples(); |
| updateComposer(); |
| } |
| |
| async function sendMessage(overrideText) { |
| if (isGenerating) { |
| stoppingCriteria.interrupt(); |
| setStatus("Stopping..."); |
| return; |
| } |
| |
| const text = (overrideText ?? promptEl.value).trim(); |
| if (!text || !generator) { |
| return; |
| } |
| |
| isGenerating = true; |
| updateComposer(); |
| stoppingCriteria.reset(); |
| |
| addMessage("user", text); |
| const assistantNode = addMessage("assistant", ""); |
| promptEl.value = ""; |
| |
| conversation.push({ role: "user", content: text }); |
| |
| let streamedText = ""; |
| let tokenCount = 0; |
| let decodeStartedAt = null; |
| |
| clearCenterState(); |
| setStatus("Generating..."); |
| setMetrics("tokens/sec: -"); |
| |
| try { |
| const output = await generator(conversation, { |
| max_new_tokens: MAX_NEW_TOKENS, |
| do_sample: false, |
| stopping_criteria: stoppingCriteria, |
| streamer: new TextStreamer(generator.tokenizer, { |
| skip_prompt: true, |
| skip_special_tokens: true, |
| callback_function: (chunk) => { |
| streamedText += chunk; |
| setStreamingMessage(assistantNode, streamedText); |
| }, |
| token_callback_function: () => { |
| const now = performance.now(); |
| if (decodeStartedAt === null) { |
| decodeStartedAt = now; |
| } |
| tokenCount += 1; |
| if (tokenCount < 2) { |
| setMetrics("tokens/sec: -"); |
| return; |
| } |
| const elapsedSeconds = Math.max( |
| (now - decodeStartedAt) / 1000, |
| 0.001, |
| ); |
| const tokensPerSecond = ( |
| (tokenCount - 1) / |
| elapsedSeconds |
| ).toFixed(1); |
| setMetrics(`tokens/sec: ${tokensPerSecond}`); |
| }, |
| }), |
| }); |
| |
| const assistantText = |
| output?.[0]?.generated_text?.at(-1)?.content?.trim() || |
| streamedText.trim() || |
| "No response generated."; |
| assistantNode.textContent = assistantText; |
| conversation.push({ role: "assistant", content: assistantText }); |
| |
| if (tokenCount >= 2 && decodeStartedAt !== null) { |
| const elapsedSeconds = Math.max( |
| (performance.now() - decodeStartedAt) / 1000, |
| 0.001, |
| ); |
| const tokensPerSecond = ((tokenCount - 1) / elapsedSeconds).toFixed( |
| 1, |
| ); |
| setMetrics(`tokens/sec: ${tokensPerSecond}`); |
| } else { |
| setMetrics("tokens/sec: -"); |
| } |
| setStatus("Ready"); |
| } catch (error) { |
| console.error(error); |
| assistantNode.textContent = |
| "Something went wrong while generating a response."; |
| assistantNode.classList.add("error"); |
| setStatus(error.message, { error: true }); |
| setMetrics("tokens/sec: -"); |
| } finally { |
| isGenerating = false; |
| updateComposer(); |
| if (generator && !loadFailed) { |
| promptEl.focus(); |
| } |
| } |
| } |
| |
| sendButton.addEventListener("click", sendMessage); |
| resetButton.addEventListener("click", resetChat); |
| |
| promptEl.addEventListener("keydown", (event) => { |
| if (event.key === "Enter" && !event.shiftKey) { |
| event.preventDefault(); |
| sendMessage(); |
| } |
| }); |
| |
| updateComposer(); |
| |
| loadModel().catch((error) => { |
| console.error(error); |
| loadFailed = true; |
| setStatus(error.message, { error: true }); |
| setMetrics("tokens/sec: -"); |
| updateComposer(); |
| }); |
| </script> |
| </body> |
| </html> |
|
|