in-browser-chat / index.html
andreolf's picture
In-browser chat via transformers.js (SmolLM2-360M, client-side)
e309efd verified
Raw History Blame Contribute Delete
5.49 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>In-Browser Chat — SmolLM2-360M</title>
<style>
:root { --bg:#0f1020; --card:#1a1b34; --ink:#eef; --muted:#9aa; --accent:#8b7bff;
--user:#2b2d55; --bot:#20223f; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; }
body { font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg);
color:var(--ink); display:flex; flex-direction:column; height:100vh; }
header { padding:14px 18px; border-bottom:1px solid #262744; }
header h1 { margin:0; font-size:17px; }
header p { margin:4px 0 0; font-size:12px; color:var(--muted); }
header a { color:var(--accent); text-decoration:none; }
#status { padding:10px 18px; font-size:13px; color:var(--muted); background:var(--card); }
#bar { height:4px; background:#262744; border-radius:2px; overflow:hidden; margin-top:8px; display:none; }
#bar > div { height:100%; width:0%; background:linear-gradient(90deg,#8b7bff,#5b8bff); transition:width .2s; }
#chat { flex:1; overflow-y:auto; padding:18px; display:flex; flex-direction:column; gap:12px; }
.msg { max-width:80%; padding:10px 14px; border-radius:14px; font-size:14px; line-height:1.5; white-space:pre-wrap; }
.user { align-self:flex-end; background:var(--user); border-bottom-right-radius:4px; }
.bot { align-self:flex-start; background:var(--bot); border-bottom-left-radius:4px; }
footer { display:flex; gap:8px; padding:14px 18px; border-top:1px solid #262744; }
#input { flex:1; background:var(--card); color:var(--ink); border:1px solid #333; border-radius:10px;
padding:10px 12px; font-size:14px; resize:none; }
button { background:var(--accent); color:#fff; border:0; border-radius:10px; padding:0 18px;
font-size:14px; font-weight:600; cursor:pointer; }
button:disabled { opacity:.5; cursor:not-allowed; }
.hint { font-size:11px; color:var(--muted); }
</style>
</head>
<body>
<header>
<h1>🤖 In-Browser Chat — SmolLM2-360M</h1>
<p>Runs <b>100% in your browser</b> via
<a href="https://github.com/huggingface/transformers.js" target="_blank">transformers.js</a>.
No server, no API calls — the model
(<a href="https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct" target="_blank">SmolLM2-360M-Instruct</a>)
downloads once and generates locally. Uses WebGPU when available, WASM otherwise.</p>
</header>
<div id="status">Click send to load the model (~300&nbsp;MB, cached after first load).
<div id="bar"><div></div></div>
</div>
<div id="chat"></div>
<footer>
<textarea id="input" rows="1" placeholder="Say hello…"></textarea>
<button id="send">Send</button>
</footer>
<script type="module">
import { pipeline, TextStreamer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3";
const chat = document.getElementById("chat");
const input = document.getElementById("input");
const send = document.getElementById("send");
const status = document.getElementById("status");
const bar = document.getElementById("bar");
const barFill = bar.firstElementChild;
let generator = null;
let loading = false;
const messages = [{ role: "system", content: "You are a friendly, concise assistant." }];
function addMsg(role, text) {
const d = document.createElement("div");
d.className = "msg " + (role === "user" ? "user" : "bot");
d.textContent = text;
chat.appendChild(d);
chat.scrollTop = chat.scrollHeight;
return d;
}
async function ensureModel() {
if (generator) return generator;
loading = true;
send.disabled = true;
bar.style.display = "block";
const hasGPU = "gpu" in navigator;
status.firstChild.textContent =
`Loading SmolLM2-360M on ${hasGPU ? "WebGPU" : "WASM"}… `;
generator = await pipeline("text-generation", "HuggingFaceTB/SmolLM2-360M-Instruct", {
dtype: hasGPU ? "q4f16" : "q4",
device: hasGPU ? "webgpu" : "wasm",
progress_callback: (p) => {
if (p.status === "progress" && p.total) {
const pct = Math.round((p.loaded / p.total) * 100);
barFill.style.width = pct + "%";
status.firstChild.textContent = `Downloading ${p.file?.split("/").pop() || ""} — ${pct}% `;
}
},
});
bar.style.display = "none";
status.firstChild.textContent = "Model ready — chatting locally in your browser. ";
loading = false;
send.disabled = false;
return generator;
}
async function run() {
const text = input.value.trim();
if (!text || loading) return;
input.value = "";
addMsg("user", text);
messages.push({ role: "user", content: text });
const gen = await ensureModel();
const botEl = addMsg("bot", "");
send.disabled = true;
let acc = "";
const streamer = new TextStreamer(gen.tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (t) => { acc += t; botEl.textContent = acc; chat.scrollTop = chat.scrollHeight; },
});
const out = await gen(messages, { max_new_tokens: 256, do_sample: true, temperature: 0.7, top_p: 0.9, streamer });
const full = out[0].generated_text.at(-1).content;
botEl.textContent = full;
messages.push({ role: "assistant", content: full });
send.disabled = false;
input.focus();
}
send.addEventListener("click", run);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); run(); }
});
</script>
</body>
</html>