| | <!doctype html> |
| |
|
| | <html lang="en"> |
| | <head> |
| | <meta charset="utf-8" /> |
| | <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| | <title>Browser LLM (WASM, mobile)</title> |
| | <style> |
| | :root { --bg:#0b0d10; --card:#14171b; --muted:#9aa4af; --accent:#6ee7b7; --danger:#f87171; --text:#dce3ea; } |
| | * { box-sizing:border-box; } |
| | body { margin:0; background:var(--bg); color:var(--text); font:16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Apple Color Emoji","Segoe UI Emoji"; } |
| | header { padding:14px 16px; border-bottom:1px solid #21262c; display:flex; gap:10px; align-items:center; } |
| | header h1 { font-size:16px; margin:0; font-weight:600; } |
| | header .pill { font-size:12px; color:var(--bg); background:var(--accent); padding:.2rem .55rem; border-radius:999px; font-weight:700; letter-spacing:.02em; } |
| | main { display:grid; grid-template-rows:auto 1fr auto; height:calc(100dvh - 58px); } |
| | .bar { display:flex; flex-wrap:wrap; gap:8px; padding:10px 12px; background:#0f1216; border-bottom:1px solid #21262c; align-items:center; } |
| | select, input[type="number"], input[type="text"] { background:var(--card); color:var(--text); border:1px solid #29313a; border-radius:10px; padding:8px 10px; } |
| | button { background:#1c2128; color:var(--text); border:1px solid #2a323c; border-radius:12px; padding:10px 12px; font-weight:600; cursor:pointer; } |
| | button.primary { background:var(--accent); color:#08261b; border:none; } |
| | button.ghost { background:transparent; border-color:#2a323c; } |
| | button:disabled { opacity:.6; cursor:not-allowed; } |
| | .grow { flex:1 1 auto; } |
| | .progress { width:160px; height:8px; background:#1a1f25; border-radius:999px; overflow:hidden; border:1px solid #25303a; } |
| | .progress > i { display:block; height:100%; width:0%; background:linear-gradient(90deg,#34d399,#10b981); transition:width .25s ease; } |
| | #stats { font-size:12px; color:var(--muted); display:flex; gap:10px; align-items:center; } |
| | #chat { padding:14px; overflow:auto; background:linear-gradient(#0b0d10, #0d1117); } |
| | .msg { max-width:820px; margin:0 auto 10px auto; display:flex; gap:10px; align-items:flex-start; } |
| | .msg .bubble { background:var(--card); padding:12px 14px; border-radius:16px; border:1px solid #242c35; white-space:pre-wrap; } |
| | .msg.user .bubble { background:#1d2330; } |
| | .msg.assistant .bubble { background:#151c24; } |
| | .role { font-size:12px; color:var(--muted); min-width:68px; text-transform:uppercase; letter-spacing:.04em; } |
| | .inputbar { display:flex; gap:8px; padding:10px; border-top:1px solid #21262c; background:#0f1216; } |
| | textarea { resize:none; height:64px; padding:10px 12px; flex:1 1 auto; border-radius:12px; border:1px solid #2a323c; background:var(--card); color:var(--text); } |
| | .tiny { font-size:12px; color:var(--muted); } |
| | .warn { color:var(--danger); font-weight:600; } |
| | .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } |
| | .spacer { flex:1; } |
| | a { color:#93c5fd; } |
| | details { margin-left:8px; } |
| | .note { font-size:12px; color:var(--muted); max-width:720px; } |
| | </style> |
| | </head> |
| | <body> |
| | <header> |
| | <h1>Browser LLM</h1> |
| | <span class="pill">WASM • CPU‑only</span> |
| | <span id="isoNote" class="tiny"></span> |
| | </header> |
| |
|
| | <main> |
| | <div class="bar"> |
| | <label for="model">Model:</label> |
| | |
| | <select id="model" class="grow"> |
| | |
| | <option selected value='{"id":"ggml-org/gemma-3-270m-GGUF","file":"gemma-3-270m-Q8_0.gguf","label":"Gemma‑3‑270M Q8_0 (≈292 MB)"}'>Gemma‑3‑270M Q8_0 (≈292 MB)</option> |
| | |
| | <option value='{"id":"mradermacher/OpenELM-270M-Instruct-GGUF","file":"OpenELM-270M-Instruct.Q3_K_S.gguf","label":"OpenELM‑270M‑Instruct Q3_K_S (≈134 MB)"}'>OpenELM‑270M‑Instruct Q3_K_S (≈134 MB)</option> |
| | <option value='{"id":"mradermacher/OpenELM-270M-Instruct-GGUF","file":"OpenELM-270M-Instruct.Q4_K_M.gguf","label":"OpenELM‑270M‑Instruct Q4_K_M (≈175 MB)"}'>OpenELM‑270M‑Instruct Q4_K_M (≈175 MB)</option> |
| |
|
| | ``` |
| | |
| | <option value='{"id":"mav23/SmolLM-135M-Instruct-GGUF","file":"smollm-135m-instruct.Q3_K_S.gguf","label":"SmolLM‑135M‑Instruct Q3_K_S (≈88 MB)"}'>SmolLM‑135M‑Instruct Q3_K_S (≈88 MB)</option> |
| | <option value='{"id":"QuantFactory/SmolLM-360M-Instruct-GGUF","file":"SmolLM-360M-Instruct.Q3_K_S.gguf","label":"SmolLM‑360M‑Instruct Q3_K_S (≈219 MB)"}'>SmolLM‑360M‑Instruct Q3_K_S (≈219 MB)</option> |
| |
|
| | |
| | <option value='{"id":"Qwen/Qwen2.5-0.5B-Instruct-GGUF","file":"qwen2.5-0.5b-instruct-q3_k_m.gguf","label":"Qwen2.5‑0.5B‑Instruct Q3_K_M (≈432 MB)"}'>Qwen2.5‑0.5B‑Instruct Q3_K_M (≈432 MB)</option> |
| | <option value='{"id":"Qwen/Qwen2.5-0.5B-Instruct-GGUF","file":"qwen2.5-0.5b-instruct-q4_k_m.gguf","label":"Qwen2.5‑0.5B‑Instruct Q4_K_M (≈491 MB)"}'>Qwen2.5‑0.5B‑Instruct Q4_K_M (≈491 MB)</option> |
| |
|
| | |
| | <option value='{"id":"TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF","file":"tinyllama-1.1b-chat-v1.0.Q3_K_S.gguf","label":"TinyLlama‑1.1B‑Chat Q3_K_S (≈500 MB)"}'>TinyLlama‑1.1B‑Chat Q3_K_S (≈500 MB)</option> |
| |
|
| | |
| | <option value='{"id":"QuantFactory/SmolLM-360M-GGUF","file":"SmolLM-360M.Q4_0.gguf","label":"SmolLM2‑360M Q4_0 (≈229 MB)"}'>SmolLM2‑360M Q4_0 (≈229 MB)</option> |
| | <option value='{"id":"QuantFactory/SmolLM-360M-GGUF","file":"SmolLM-360M.Q3_K_S.gguf","label":"SmolLM2‑360M Q3_K_S (≈219 MB, faster)"}'>SmolLM2‑360M Q3_K_S (≈219 MB, faster)</option> |
| | <option value='{"id":"QuantFactory/SmolLM-360M-GGUF","file":"SmolLM-360M.Q2_K.gguf","label":"SmolLM2‑360M Q2_K (≈200 MB, min RAM / quality drop)"}'>SmolLM2‑360M Q2_K (≈200 MB, min RAM / quality drop)</option> |
| |
|
| | |
| | <option value='{"custom":true,"label":"Custom HF GGUF (e.g., Gemma‑3‑270M)"}'>Custom HF GGUF (e.g., Gemma‑3‑270M)</option> |
| | </select> |
| |
|
| | <details id="customBox"> |
| | <summary class="tiny">Custom GGUF (paste HF repo + file)</summary> |
| | <div class="row"> |
| | <label class="tiny">HF repo id</label> |
| | <input id="customRepo" type="text" placeholder="e.g. google/gemma-3-270m-GGUF (when available)" style="width:280px" /> |
| | <label class="tiny">file</label> |
| | <input id="customFile" type="text" placeholder="e.g. gemma-3-270m.Q4_0.gguf" style="width:240px" /> |
| | </div> |
| | <div class="note">Note: official <a href="https://huggingface.co/google/gemma-3-270m" target="_blank" rel="noreferrer">Gemma‑3‑270M</a> is the base HF repo. A ready‑to‑use public GGUF is now available at <a href="https://huggingface.co/ggml-org/gemma-3-270m-GGUF" target="_blank" rel="noreferrer">ggml‑org/gemma‑3‑270m‑GGUF</a> (currently providing <code>gemma-3-270m-Q8_0.gguf</code> ≈292 MB). For maximum speed on low‑RAM phones, the OpenELM‑270M‑Instruct Q3_K_S option above is even lighter, but Gemma‑3‑270M offers strong quality for its size.</div> |
| | </details> |
| |
|
| | <div class="row"> |
| | <label>Max new tokens</label> |
| | <input id="nPredict" type="number" min="1" max="512" step="1" value="128" /> |
| | </div> |
| | <div class="row"> |
| | <label>Temp</label><input id="temp" type="number" min="0" max="2" step="0.1" value="0.7" style="width:80px" /> |
| | <label>Top‑p</label><input id="topp" type="number" min="0" max="1" step="0.05" value="0.9" style="width:80px" /> |
| | <label>Top‑k</label><input id="topk" type="number" min="1" max="100" step="1" value="40" style="width:80px" /> |
| | </div> |
| |
|
| | <div class="spacer"></div> |
| |
|
| | <button id="loadBtn" class="primary">Load model</button> |
| | <button id="unloadBtn" class="ghost" disabled>Unload</button> |
| |
|
| | <div class="progress" title="download progress"><i id="prog"></i></div> |
| | <div id="stats">idle</div> |
| | </div> |
| |
|
| | <div id="chat" aria-live="polite"></div> |
| |
|
| | <form class="inputbar" id="form"> |
| | <textarea id="input" placeholder="Ask me anything…" required></textarea> |
| | <div class="row" style="flex-direction:column; gap:6px; align-items:flex-end"> |
| | <button id="sendBtn" class="primary">Send</button> |
| | <button id="stopBtn" type="button" class="ghost" disabled>Stop</button> |
| | <div class="tiny">Context kept small for mobile perf</div> |
| | </div> |
| | </form> |
| | ``` |
| |
|
| | </main> |
| |
|
| | <script type="module"> |
| | |
| | import { Wllama, LoggerWithoutDebug } from "https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/esm/index.js"; |
| | |
| | |
| | const CONFIG_PATHS = { |
| | "single-thread/wllama.wasm": "https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/esm/single-thread/wllama.wasm", |
| | "multi-thread/wllama.wasm" : "https://cdn.jsdelivr.net/npm/@wllama/wllama@2.3.1/esm/multi-thread/wllama.wasm", |
| | }; |
| | |
| | |
| | const $model = document.getElementById('model'); |
| | const $load = document.getElementById('loadBtn'); |
| | const $unload= document.getElementById('unloadBtn'); |
| | const $prog = document.getElementById('prog'); |
| | const $stats = document.getElementById('stats'); |
| | const $chat = document.getElementById('chat'); |
| | const $form = document.getElementById('form'); |
| | const $input = document.getElementById('input'); |
| | const $send = document.getElementById('sendBtn'); |
| | const $stop = document.getElementById('stopBtn'); |
| | const $iso = document.getElementById('isoNote'); |
| | const $customBox = document.getElementById('customBox'); |
| | const $customRepo = document.getElementById('customRepo'); |
| | const $customFile = document.getElementById('customFile'); |
| | |
| | |
| | const decoder = new TextDecoder(); |
| | const wllama = new Wllama(CONFIG_PATHS, { logger: LoggerWithoutDebug }); |
| | let aborter = null; |
| | let loaded = false; |
| | let eotToken = -1; |
| | let sysPrompt = "You are a helpful, concise assistant. Keep answers short and clear."; |
| | |
| | |
| | const LOAD_CONFIG = { |
| | n_ctx: 768, |
| | n_batch: 48, |
| | cache_type_k: "q4_0", |
| | cache_type_v: "f16", |
| | flash_attn: false, |
| | progressCallback: ({ loaded, total }) => { |
| | const pct = (total && total > 0) ? Math.round(loaded / total * 100) : 0; |
| | $prog.style.width = pct + '%'; |
| | } |
| | }; |
| | |
| | const messages = [ { role: "system", content: sysPrompt } ]; |
| | |
| | |
| | const ui = { |
| | add(role, text) { |
| | const row = document.createElement('div'); |
| | row.className = 'msg ' + role; |
| | row.innerHTML = ` |
| | <div class="role">${role}</div> |
| | <div class="bubble"></div> |
| | `; |
| | row.querySelector('.bubble').textContent = text; |
| | $chat.appendChild(row); |
| | $chat.scrollTop = $chat.scrollHeight; |
| | return row.querySelector('.bubble'); |
| | }, |
| | setStats(txt) { $stats.textContent = txt; } |
| | }; |
| | |
| | function noteIsolation() { |
| | if (!crossOriginIsolated) { |
| | $iso.innerHTML = 'Single‑thread mode (serve with COOP/COEP for multithread)'; |
| | } else { |
| | $iso.textContent = 'Cross‑origin isolated: multithread on'; |
| | } |
| | } |
| | noteIsolation(); |
| | |
| | function truncateHistoryForMobile(maxTokensRough = 900) { |
| | const maxChars = maxTokensRough * 4; |
| | function clip(s) { return s.length <= maxChars ? s : ('…' + s.slice(s.length - maxChars)); } |
| | let kept = [messages[0]]; |
| | const lastTurns = messages.slice(-8); |
| | for (const m of lastTurns) kept.push({ role: m.role, content: clip(m.content) }); |
| | messages.length = 0; messages.push(...kept); |
| | } |
| | |
| | function getSelectedModel() { |
| | const parsed = JSON.parse($model.value); |
| | if (parsed.custom) { |
| | const id = ($customRepo.value || '').trim(); |
| | const file = ($customFile.value || '').trim(); |
| | if (!id || !file) throw new Error('Enter HF repo id and GGUF file for custom model.'); |
| | return { id, file, label: `Custom: ${id}/${file}` }; |
| | } |
| | return parsed; |
| | } |
| | |
| | async function ensureLoaded() { |
| | if (loaded) return; |
| | $prog.style.width = '0%'; |
| | const choice = getSelectedModel(); |
| | ui.setStats(`Fetching ${choice.file}…`); |
| | try { |
| | await wllama.loadModelFromHF(choice.id, choice.file, LOAD_CONFIG); |
| | } catch (e) { |
| | |
| | throw new Error(`Load failed for ${choice.id}/${choice.file}. If the repo is gated or lacks CORS, try a public mirror / different quant. Details: ${e?.message || e}`); |
| | } |
| | loaded = true; |
| | eotToken = wllama.getEOT(); |
| | const meta = await wllama.getModelMetadata(); |
| | const ctx = wllama.getLoadedContextInfo(); |
| | const thr = wllama.getNumThreads?.() ?? 1; |
| | ui.setStats(`Loaded ${choice.file} • ${meta.n_params?.toLocaleString?.() || ''} params • ctx ${ctx.n_ctx} • threads ${thr}`); |
| | $load.disabled = true; $unload.disabled = false; |
| | } |
| | |
| | async function unloadModel() { |
| | try { await wllama.exit(); } catch {} |
| | loaded = false; |
| | $load.disabled = false; $unload.disabled = true; |
| | $prog.style.width = '0%'; |
| | ui.setStats('idle'); |
| | } |
| | |
| | |
| | document.getElementById('loadBtn').addEventListener('click', ensureLoaded); |
| | document.getElementById('unloadBtn').addEventListener('click', unloadModel); |
| | document.getElementById('stopBtn').addEventListener('click', () => aborter?.abort()); |
| | |
| | $model.addEventListener('change', () => { |
| | const isCustom = JSON.parse($model.value).custom === true; |
| | $customBox.open = isCustom; |
| | }); |
| | |
| | $form.addEventListener('submit', async (ev) => { |
| | ev.preventDefault(); |
| | const text = ($input.value || '').trim(); |
| | if (!text) return; |
| | await ensureLoaded(); |
| | |
| | messages.push({ role: 'user', content: text }); |
| | const userBubble = ui.add('user', text); |
| | $input.value = ''; |
| | |
| | const assistantBubble = ui.add('assistant', ''); |
| | truncateHistoryForMobile(900); |
| | |
| | $send.disabled = true; $stop.disabled = true; |
| | aborter = new AbortController(); |
| | |
| | const nPredict = parseInt(document.getElementById('nPredict').value, 10); |
| | const temp = parseFloat(document.getElementById('temp').value); |
| | const top_p = parseFloat(document.getElementById('topp').value); |
| | const top_k = parseInt(document.getElementById('topk').value, 10); |
| | |
| | const t0 = performance.now(); |
| | let outText = ''; |
| | |
| | try { |
| | $stop.disabled = false; |
| | const stream = await wllama.createChatCompletion(messages, { |
| | stream: true, |
| | useCache: true, |
| | nPredict, |
| | sampling: { temp, top_p, top_k }, |
| | stopTokens: eotToken > 0 ? [eotToken] : undefined, |
| | abortSignal: aborter.signal |
| | }); |
| | |
| | for await (const chunk of stream) { |
| | const piece = new TextDecoder().decode(chunk.piece); |
| | outText += piece; |
| | assistantBubble.textContent = outText; |
| | $chat.scrollTop = $chat.scrollHeight; |
| | } |
| | const dt = (performance.now() - t0) / 1000; |
| | const tokSec = Math.max(1, Math.round(outText.length / 4)) / dt; |
| | ui.setStats(`gen: ${tokSec.toFixed(1)} tok/s (rough)`); |
| | messages.push({ role: 'assistant', content: outText }); |
| | } catch (err) { |
| | if (err && err.name === 'AbortError') { |
| | assistantBubble.textContent += '\n\n[stopped]'; |
| | } else { |
| | console.error(err); |
| | assistantBubble.innerHTML += `\n\n<span class="warn">Error: ${String(err)}</span>`; |
| | } |
| | } finally { |
| | $send.disabled = false; $stop.disabled = true; |
| | aborter = null; |
| | } |
| | }); |
| | |
| | |
| | $input.addEventListener('keydown', (e) => { |
| | if (e.key === 'Enter' && !e.shiftKey) { |
| | e.preventDefault(); |
| | $send.click(); |
| | } |
| | }); |
| | </script> |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | </body> |
| | </html> |
| |
|