gemma-3-1b-WebGPU / index.html
Xenova's picture
Xenova HF Staff
Update index.html
4515c09 verified
Raw
History Blame Contribute Delete
17.4 kB
<!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>