Spaces:
Running
Running
Download index.html from naklitechie/diskformer-chat: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/naklitechie/diskformer-chat/resolve/main/index.html
- Command line
-
hf download hf://spaces/naklitechie/diskformer-chat/index.html
-
curl -L -o index.html https://huggingface.co/spaces/naklitechie/diskformer-chat/resolve/main/index.html
23.6 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>diskformer chat</title> | |
| <meta name="description" content="Run a mixture-of-experts model larger than your GPU memory in a browser tab: llama.cpp's WebGPU backend with the experts paged in from the browser's on-disk storage (OPFS) by diskformer.js."> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:site_name" content="diskformer.js"> | |
| <meta property="og:title" content="diskformer chat: models larger than your GPU, in a browser tab"> | |
| <meta property="og:description" content="llama.cpp on WebGPU; the experts stay on disk and stream in as each token needs them."> | |
| <meta property="og:url" content="https://diskformer.naklitechie.com/"> | |
| <meta property="og:image" content="https://diskformer.naklitechie.com/social.png"> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:image" content="https://diskformer.naklitechie.com/social.png"> | |
| <style> | |
| /* One palette, whatever the OS theme: dull cream with blue accents. */ | |
| :root { color-scheme: light; --bg: #efe8d8; --fg: #2b2720; --muted: #776f61; --line: #ddd2bc; --card: #f8f3e7; --accent: #2f5d9e; --accent-soft: #dfe6ef; --bad: #a3392b; --code: #ebe3d1; } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; } | |
| p { margin: 0 0 10px; } | |
| a { color: var(--accent); } | |
| .muted { color: var(--muted); } | |
| /* Two panes: the chat on the left, the model switcher on the right; stacked (switcher first) on a narrow screen. */ | |
| .app { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 16px; max-width: 1280px; margin: 0 auto; padding: 16px; height: 100%; } | |
| .pane { background: var(--card); border: 1px solid var(--line); border-radius: 10px; min-height: 0; } | |
| #chat { display: flex; flex-direction: column; padding: 14px; } | |
| #side { padding: 14px; overflow-y: auto; } | |
| @media (max-width: 860px) { .app { grid-template-columns: minmax(0, 1fr); height: auto; padding: 10px; } #side { order: -1; } #chat { min-height: 70vh; } } | |
| h1 { font-size: 18px; margin: 0 0 4px; } | |
| h2 { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 16px 0 8px; } | |
| label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); } | |
| select, input, textarea, button { font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 6px 9px; } | |
| input[type=number] { width: 90px; } | |
| input[type=file] { width: 100%; min-width: 0; font-size: 13px; } | |
| button { cursor: pointer; } | |
| button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| button:disabled { opacity: .5; cursor: default; } | |
| .row { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: end; } | |
| .mrow > div { min-width: 0; } | |
| .mrow { border-top: 1px solid var(--line); padding: 9px 0; display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; align-items: center; } | |
| .mrow:first-child { border-top: 0; } | |
| .mrow.on { box-shadow: inset 3px 0 0 var(--accent); padding-left: 10px; } | |
| .mrow .info { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } | |
| .mrow .act { grid-row: span 2; white-space: nowrap; } | |
| .mrow .act button { margin-left: 4px; padding: 4px 9px; font-size: 13px; } | |
| .bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin: 12px 0 6px; } | |
| .bar span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .3s; } | |
| #status { font-size: 13px; } #status.bad { color: var(--bad); } | |
| .meter { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .meter div { border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; } | |
| .meter b { display: block; font-size: 18px; font-variant-numeric: tabular-nums; } | |
| .meter span { font-size: 12px; color: var(--muted); } | |
| #log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding: 2px 2px 12px; } | |
| #empty { margin: auto; max-width: 440px; text-align: center; } | |
| .msg { overflow-wrap: anywhere; padding: 9px 12px; border-radius: 9px; border: 1px solid var(--line); } | |
| .msg.user { align-self: flex-end; background: var(--accent-soft); border-color: #c9d5e5; max-width: 85%; white-space: pre-wrap; } | |
| .msg.model { background: var(--card); } | |
| .msg.model p { margin: 0 0 8px; } .msg.model p:last-child { margin: 0; } | |
| .msg.model details.think { color: var(--muted); font-size: 0.92em; margin: 0 0 8px; } .msg.model details.think summary { cursor: pointer; } | |
| .msg code { background: var(--code); border-radius: 4px; padding: 1px 4px; font-size: 13px; } | |
| .msg pre { background: var(--code); border-radius: 6px; padding: 8px 10px; overflow-x: auto; margin: 0 0 8px; } | |
| .msg pre code { background: none; padding: 0; } | |
| .msg .note { font-size: 12px; color: var(--muted); margin-top: 6px; } | |
| form { display: flex; gap: 8px; align-items: end; border-top: 1px solid var(--line); padding-top: 12px; } | |
| textarea { flex: 1; resize: vertical; min-height: 44px; } | |
| details { font-size: 12px; color: var(--muted); margin-top: 14px; } #devlog { white-space: pre-wrap; max-height: 200px; overflow: auto; font-family: ui-monospace, monospace; } | |
| footer { font-size: 12px; color: var(--muted); margin-top: 14px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <section id="chat" class="pane"> | |
| <div id="log"><div id="empty" class="muted"> | |
| <h1 style="color:var(--fg)">diskformer chat</h1> | |
| <p>Run a mixture-of-experts model larger than your GPU memory, in this tab. The model file stays in this | |
| browser's private storage on your disk (OPFS). llama.cpp's WebGPU backend keeps the dense layers and a small | |
| cache of experts on the GPU; diskformer reads every other expert from the file as each token needs it.</p> | |
| <p>Pick a model on the right. The first time, it downloads once into this browser's storage.</p> | |
| </div></div> | |
| <form id="ask"> | |
| <textarea id="q" rows="2" placeholder="Load a model to start"></textarea> | |
| <button class="primary" id="send">Send</button> | |
| <button type="button" id="stop" disabled>Stop</button> | |
| <button type="button" id="clear">New chat</button> | |
| </form> | |
| </section> | |
| <aside id="side" class="pane"> | |
| <h1>Models</h1> | |
| <p class="muted" style="font-size:13px">Chrome or Edge 137 or later, with WebGPU. Nothing leaves this tab except the model download.</p> | |
| <p id="framed" hidden style="font-size:13px">This page is inside a frame, where browser storage can be partitioned or capped. | |
| <a id="ownTab" target="_blank" rel="noopener">Open it in its own tab</a> to store large models.</p> | |
| <p id="unsupported" class="muted" hidden></p> | |
| <div id="models"></div> | |
| <div class="row" style="margin-top:10px"> | |
| <label>GPU budget (GB) <input id="budget" type="number" min="1" step="0.5" value="4"></label> | |
| <label>Context <select id="ctx"><option>1024</option><option selected>2048</option><option>4096</option></select></label> | |
| </div> | |
| <label style="margin-top:10px">Or a GGUF from your disk <input id="file" type="file" accept=".gguf"></label> | |
| <div class="bar"><span id="fill"></span></div> | |
| <p id="status" class="muted">No model loaded.</p> | |
| <div id="meterCard" hidden> | |
| <h2>This model</h2> | |
| <div class="meter"> | |
| <div><b id="mDisk">–</b><span>file on disk (OPFS)</span></div> | |
| <div><b id="mGpu">–</b><span id="mGpuNote">on the GPU</span></div> | |
| <div><b id="mSlots">–</b><span id="mSlotsNote">experts cached on the GPU</span></div> | |
| <div><b id="mSpeed">–</b><span>decode speed</span></div> | |
| <div><b id="mRead">–</b><span>read from disk per token</span></div> | |
| </div> | |
| </div> | |
| <details><summary>llama.cpp log</summary><div id="devlog"></div></details> | |
| <footer>Greedy decoding, so the same question gets the same answer. Built on | |
| <a href="https://github.com/NakliTechie/diskformer.js">diskformer.js</a> and | |
| <a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a>'s WebGPU backend, with expert paging from the | |
| <a href="https://github.com/NakliTechie/llama.cpp/tree/webgpu-diskformer">webgpu-diskformer</a> branch. | |
| Each model is downloaded from Hugging Face under its own license. | |
| The earlier hand-written engines: <a href="classic/index.html">classic page</a>.</footer> | |
| </aside> | |
| </div> | |
| <script type="module"> | |
| // Models with a recorded gate on this path (examples/chat/refs, plan/width-2026-10-13.md). Sizes are the exact | |
| // file sizes, so a download is complete only at that size. | |
| const HF = (repo, file) => `https://huggingface.co/${repo}/resolve/main/${file}`; | |
| const CATALOG = [ | |
| { name: 'LFM2-8B-A1B-Q4_K_M.gguf', label: 'LFM2 8B-A1B', note: 'Liquid AI · 1B active', size: 5044779712, url: HF('LiquidAI/LFM2-8B-A1B-GGUF', 'LFM2-8B-A1B-Q4_K_M.gguf') }, | |
| { name: 'granite-4.0-h-tiny-Q4_K_M.gguf', label: 'Granite 4.0 H Tiny', note: 'IBM · 1B active · Mamba-2 hybrid', size: 4230976352, url: HF('ibm-granite/granite-4.0-h-tiny-GGUF', 'granite-4.0-h-tiny-Q4_K_M.gguf') }, | |
| { name: 'OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf', label: 'OLMoE 1B-7B', note: 'Ai2 · 1B active', size: 4213512192, url: HF('allenai/OLMoE-1B-7B-0125-Instruct-GGUF', 'OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf') }, | |
| { name: 'gpt-oss-20b-MXFP4.gguf', label: 'gpt-oss 20B', note: 'OpenAI · 3.6B active · reasons first', size: 12109566624, url: HF('ggml-org/gpt-oss-20b-GGUF', 'gpt-oss-20b-MXFP4.gguf') }, | |
| { name: 'Qwen3-30B-A3B-Instruct-2507-Q3_K_M.gguf', label: 'Qwen3 30B-A3B', note: 'Qwen · 3B active', size: 14711847328, url: HF('unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF', 'Qwen3-30B-A3B-Instruct-2507-Q3_K_M.gguf') }, | |
| { name: 'Qwen3.6-35B-A3B-UD-Q3_K_M.gguf', label: 'Qwen3.6 35B-A3B', note: 'Qwen · 3B active · reasons first', size: 16600710112, url: HF('unsloth/Qwen3.6-35B-A3B-GGUF', 'Qwen3.6-35B-A3B-UD-Q3_K_M.gguf') }, | |
| { name: 'GLM-4.7-Flash-Q3_K_M.gguf', label: 'GLM-4.7-Flash', note: 'Z.ai · 3B active', size: 14614624608, url: HF('unsloth/GLM-4.7-Flash-GGUF', 'GLM-4.7-Flash-Q3_K_M.gguf') }, | |
| { name: 'LFM2.5-24B-A2B-QAD-Q4_0.gguf', label: 'LFM2.5 24B-A2B', note: 'Liquid AI · 2B active · reasons first', size: 13578190880, url: HF('LiquidAI/LFM2.5-24B-A2B-GGUF', 'LFM2.5-24B-A2B-QAD-Q4_0.gguf') }, | |
| { name: 'ERNIE-4.5-21B-A3B-Thinking-Q4_K_M.gguf', label: 'ERNIE 4.5 21B-A3B', note: 'Baidu · 3B active · reasons first', size: 13331018624, url: HF('unsloth/ERNIE-4.5-21B-A3B-Thinking-GGUF', 'ERNIE-4.5-21B-A3B-Thinking-Q4_K_M.gguf') }, | |
| { name: 'Ling-mini-2.0-Q4_K_M.gguf', label: 'Ling mini 2.0', note: 'Ant Group · 1.4B active', size: 9911575072, url: HF('inclusionAI/Ling-mini-2.0-GGUF', 'Ling-mini-2.0-Q4_K_M.gguf') }, | |
| { name: 'Mellum2-12B-A2.5B-Instruct-Q4_K_M.gguf', label: 'Mellum2 12B', note: 'JetBrains · 2.5B active · code', size: 8071293600, url: HF('JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q4_K_M', 'Mellum2-12B-A2.5B-Instruct-Q4_K_M.gguf') }, | |
| { name: 'Qwen3-Next-80B-A3B-Instruct-Q4_K_M.gguf', label: 'Qwen3-Next 80B-A3B', note: 'Qwen · 3B active · proof of concept, slow', size: 48410988384, url: HF('Qwen/Qwen3-Next-80B-A3B-Instruct-GGUF', 'Qwen3-Next-80B-A3B-Instruct-Q4_K_M.gguf'), budget: 5 }, | |
| ]; | |
| const $ = (id) => document.getElementById(id); | |
| const GB = (n) => (n / 1e9).toFixed(n >= 1e10 ? 1 : 2) + ' GB'; | |
| const MB = (n) => (n / 1e6).toFixed(0) + ' MB'; | |
| const q = new URLSearchParams(location.search); | |
| let worker = new Worker(new URL('./ggml/worker.mjs', import.meta.url), { type: 'module' }); | |
| let stored = [], busy = '', loaded = null, messages = [], current = null; // busy: '' | 'download' | 'load' | 'reply' | |
| const status = (text, bad = false) => { $('status').textContent = text; $('status').className = bad ? 'bad' : 'muted'; window.progressText = text; }; | |
| const fill = (f) => { $('fill').style.width = `${Math.round(100 * f)}%`; }; | |
| addEventListener('unhandledrejection', (e) => status(`Error: ${e.reason && e.reason.message || e.reason}`, true)); | |
| if (window.self !== window.top) { $('framed').hidden = false; $('ownTab').href = location.href; } | |
| const supported = !!navigator.gpu && typeof WebAssembly.Suspending === 'function'; | |
| if (!supported) { $('unsupported').hidden = false; $('unsupported').textContent = 'This browser lacks WebGPU or WebAssembly JSPI. Use Chrome or Edge 137 or later.'; } | |
| // One message to the worker; resolves with the first reply of one of `types`, rejects on 'error'. | |
| let onMsg = () => {}; | |
| worker.onmessage = ({ data }) => onMsg(data); | |
| function send(msg, types, onOther = () => {}) { | |
| return new Promise((resolve, reject) => { | |
| onMsg = (d) => { | |
| if (d.type === 'log') { $('devlog').textContent += d.line + '\n'; return; } | |
| if (d.type === 'error') { onMsg = () => {}; reject(new Error(d.message)); return; } | |
| if (types.includes(d.type)) { onMsg = () => {}; resolve(d); return; } | |
| onOther(d); | |
| }; | |
| worker.postMessage(msg); | |
| }); | |
| } | |
| function controls() { | |
| for (const b of $('models').querySelectorAll('button')) b.disabled = !!busy && !(busy === 'download' && b.dataset.act === 'stop'); | |
| $('budget').disabled = $('ctx').disabled = $('file').disabled = !!busy || !supported; | |
| $('send').disabled = !!busy || !loaded; $('stop').disabled = busy !== 'reply'; $('clear').disabled = !!busy; | |
| } | |
| function renderModels() { | |
| const byName = Object.fromEntries(stored.map((m) => [m.name, m])); | |
| const rows = [...CATALOG, ...stored.filter((m) => !CATALOG.some((c) => c.name === m.name)).map((m) => ({ name: m.name, label: m.name, note: 'your file', size: m.size }))]; | |
| $('models').replaceChildren(...rows.map((m) => { | |
| const s = byName[m.name], el = document.createElement('div'); | |
| el.className = 'mrow' + (loaded && loaded.name === m.name ? ' on' : ''); | |
| const state = !s ? 'not stored' : s.partial ? `${GB(s.size)} of ${GB(m.size)} stored` : 'stored'; | |
| const name = document.createElement('div'); name.append(Object.assign(document.createElement('b'), { textContent: m.label })); // a stored file's own name: text, never markup | |
| const act = document.createElement('div'); act.className = 'act'; | |
| const info = document.createElement('div'); info.className = 'info'; info.textContent = `${GB(m.size)} · ${state} · ${m.note}`; | |
| const btn = (label, fn, cls, key) => { const b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; if (key) b.dataset.act = key; b.onclick = fn; act.append(b); }; | |
| if (busy === 'download' && current === m.name) btn('Stop', () => worker.postMessage({ type: 'abort' }), '', 'stop'); | |
| else if (s && !s.partial) btn(loaded && loaded.name === m.name ? 'Loaded' : 'Load', () => load(m), 'primary'); | |
| else if (m.url && supported) btn(s ? 'Resume' : 'Download', () => download(m), 'primary'); | |
| if (s) btn('Remove', () => remove(m)); | |
| el.append(name, act, info); | |
| return el; | |
| })); | |
| controls(); | |
| } | |
| async function refresh() { stored = (await send({ type: 'list' }, ['models'])).models; renderModels(); } | |
| async function download(m) { | |
| const est = navigator.storage.estimate ? await navigator.storage.estimate() : {}; | |
| const have = (stored.find((s) => s.name === m.name) || {}).size || 0; | |
| if (est.quota && est.quota - (est.usage || 0) < m.size - have) status(`Needs ${GB(m.size - have)} more; the browser allows ${GB(est.quota - (est.usage || 0))} so far and usually raises that as the download writes, while the disk has room.`); | |
| await (navigator.storage.persist ? navigator.storage.persist() : null); | |
| busy = 'download'; current = m.name; renderModels(); | |
| try { | |
| await send({ type: 'download', url: m.url, name: m.name, size: m.size }, ['models'], (d) => { | |
| if (d.type === 'progress') { | |
| fill(d.copied / d.total); | |
| const left = d.rate ? (d.total - d.copied) / d.rate : 0; | |
| status(`Downloading ${m.label} into this browser's storage (once): ${GB(d.copied)} of ${GB(d.total)}${d.rate ? ` · ${(d.rate / 1e6).toFixed(0)} MB/s · ${Math.ceil(left / 60)} min left` : ''}`); | |
| } | |
| }).then((d) => { stored = d.models; }); | |
| status(`${m.label} is stored. Load it to chat.`); | |
| } catch (e) { status(e.message, /stopped/.test(e.message) ? false : true); await refresh(); } | |
| finally { busy = ''; current = null; renderModels(); } | |
| } | |
| async function remove(m) { | |
| if (loaded && loaded.name === m.name) { await restartWorker(); } | |
| stored = (await send({ type: 'remove', name: m.name }, ['removed'])).models; | |
| status(`Removed ${m.label} from this browser's storage.`); fill(0); renderModels(); | |
| } | |
| // A fresh worker: llama.cpp holds the GPU device and the file handles until its worker ends. | |
| async function restartWorker() { | |
| worker.terminate(); | |
| worker = new Worker(new URL('./ggml/worker.mjs', import.meta.url), { type: 'module' }); | |
| worker.onmessage = ({ data }) => onMsg(data); | |
| loaded = null; $('meterCard').hidden = true; clearChat(); | |
| } | |
| async function load(m, file) { | |
| if (busy) return; | |
| if (loaded) await restartWorker(); | |
| if (m && m.budget && Number($('budget').value) < m.budget) $('budget').value = m.budget; // its experts need the room | |
| const budget = Number($('budget').value) * 1e9, ctx = Number($('ctx').value); | |
| busy = 'load'; renderModels(); fill(0); | |
| status(file ? `Copying ${file.name} into this browser's storage, then loading` : `Loading ${m.label} from this browser's storage`); | |
| const t0 = performance.now(); | |
| try { | |
| const d = await send({ type: 'load', ...(file ? { file } : { opfsName: m.name }), budget, ctx }, ['loaded'], (x) => { | |
| if (x.type === 'progress') { fill(x.copied / x.total); status(`Copying into this browser's storage (once): ${GB(x.copied)} of ${GB(x.total)}`); } | |
| else if (x.type === 'plan' && !x.plan.error) status(`Planning: ${x.plan.slots} expert slots on the GPU, then loading the dense layers`); | |
| }); | |
| if (d.rc !== 0) throw new Error(`llama.cpp could not load the model (code ${d.rc}); see the log below`); | |
| const p = d.plan, gpu = p.dense + p.kv + p.compute + d.slots * p.per_slot; | |
| loaded = { name: d.name, label: m ? m.label : d.name, size: d.size, plan: p, slots: d.slots }; | |
| $('mDisk').textContent = GB(d.size); | |
| $('mGpu').textContent = GB(gpu); | |
| $('mGpuNote').textContent = `on the GPU, budget ${GB(budget)}`; | |
| $('mSlots').textContent = `${d.slots} of ${p.n_expert}`; | |
| $('mSlotsNote').textContent = `experts per layer cached on the GPU; ${p.n_used} used per token`; | |
| $('mSpeed').textContent = '–'; $('mRead').textContent = '–'; | |
| $('meterCard').hidden = false; $('q').placeholder = `Ask ${loaded.label} something`; | |
| fill(1); | |
| status(`Loaded ${loaded.label} in ${((performance.now() - t0) / 1000).toFixed(1)} s: ${GB(d.size)} on disk, ${GB(gpu)} on the GPU.`); | |
| clearChat(); | |
| window.dfLoaded = d; | |
| } catch (e) { | |
| const m2 = /budget .* does not hold/.test(e.message) ? `${e.message}. Raise the GPU memory budget.` : `Could not load: ${e.message}`; | |
| status(m2, true); await restartWorker(); | |
| } finally { busy = ''; await refresh(); } | |
| } | |
| // Plain text with paragraphs, **bold**, *italic*, `code` and ``` blocks; everything else escaped. | |
| function render(text) { | |
| const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]); | |
| return text.split(/```/).map((part, i) => i % 2 | |
| ? `<pre><code>${esc(part.replace(/^[\w-]*\n/, ''))}</code></pre>` | |
| : part.split(/\n{2,}/).filter((p) => p.trim()).map((p) => `<p>${esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/\*(?=\S)([^*\n]+?)\*/g, '<i>$1</i>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\n/g, '<br>')}</p>`).join('')).join(''); | |
| } | |
| // Models that reason before answering: gpt-oss writes harmony channels (analysis, then final); Qwen3.5/3.6 and | |
| // others write <think>...</think>, often with the opening tag already in the prompt. | |
| function split(text) { | |
| if (text.includes('<|channel|>')) { | |
| let thinking = '', answer = ''; | |
| for (const m of text.matchAll(/<\|channel\|>(\w+)<\|message\|>([\s\S]*?)(?=<\|end\|>|<\|return\|>|<\|start\|>|$)/g)) { | |
| if (m[1] === 'final') answer += m[2]; else thinking += m[2]; | |
| } | |
| return { thinking, answer, open: !text.includes('<|channel|>final') }; | |
| } | |
| const close = text.indexOf('</think>'); | |
| if (close >= 0) return { thinking: text.slice(0, close).replace(/^\s*<think>/, ''), answer: text.slice(close + 8).replace(/^\s+/, ''), open: false }; | |
| if (/^\s*<think>/.test(text)) return { thinking: text.replace(/^\s*<think>/, ''), answer: '', open: true }; | |
| return { thinking: '', answer: text, open: false }; | |
| } | |
| function renderReply(text) { | |
| const r = split(text); | |
| const think = r.thinking.trim() ? `<details class="think"${r.open ? ' open' : ''}><summary>${r.open ? 'Thinking…' : 'Thought'}</summary>${render(r.thinking)}</details>` : ''; | |
| return think + render(r.answer); | |
| } | |
| function bubble(role, text) { $('empty').hidden = true; const d = document.createElement('div'); d.className = `msg ${role}`; if (role === 'user') d.textContent = text; else d.innerHTML = render(text); $('log').append(d); $('log').scrollTop = $('log').scrollHeight; return d; } | |
| $('ask').onsubmit = async (e) => { | |
| e.preventDefault(); | |
| const text = $('q').value.trim(); | |
| if (!text || !loaded || busy) return; | |
| $('q').value = ''; | |
| messages.push({ role: 'user', content: text }); | |
| bubble('user', text); | |
| const out = bubble('model', '…'); | |
| busy = 'reply'; controls(); | |
| let reply = '', tokens = 0, tFirst = 0; | |
| try { | |
| const d = await send({ type: 'chat', messages, n: 512 }, ['done'], (x) => { | |
| if (x.type !== 'piece') return; | |
| if (!tokens) tFirst = performance.now(); | |
| tokens++; reply += x.piece; out.innerHTML = renderReply(reply); $('log').scrollTop = $('log').scrollHeight; | |
| if (tokens > 2) $('mSpeed').textContent = `${((tokens - 1) / ((performance.now() - tFirst) / 1000)).toFixed(1)} tok/s`; | |
| }); | |
| const s = d.stats; | |
| reply = d.text; out.innerHTML = renderReply(reply); | |
| if (s.decode_tokens > 1) $('mSpeed').textContent = `${(s.decode_tokens / (s.decode_ms / 1000)).toFixed(1)} tok/s`; | |
| if (s.decode_tokens > 0) $('mRead').textContent = MB(s.decode_read_bytes / s.decode_tokens); | |
| const note = document.createElement('div'); note.className = 'note'; | |
| note.textContent = `${s.decode_tokens} tokens · ${(s.decode_tokens / Math.max(0.001, s.decode_ms / 1000)).toFixed(1)} tok/s · prompt ${s.prompt_tokens} tokens in ${(s.prompt_ms / 1000).toFixed(1)} s` | |
| + (s.aborted ? ' · stopped' : '') + (s.context_full ? ' · the context is full: start a new chat' : ''); | |
| out.append(note); | |
| // history keeps the answer only, as the chat templates expect | |
| const answer = split(reply).answer.trim() || reply; | |
| if (reply) messages.push({ role: 'assistant', content: answer }); else messages.pop(); | |
| window.dfResult = d; | |
| } catch (err) { out.textContent = `Error: ${err.message}`; messages.pop(); } | |
| finally { busy = ''; controls(); } | |
| }; | |
| $('stop').onclick = () => worker.postMessage({ type: 'abort' }); | |
| function clearChat() { messages = []; $('log').replaceChildren($('empty')); $('empty').hidden = false; } | |
| $('clear').onclick = clearChat; | |
| $('q').onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('ask').requestSubmit(); } }; | |
| $('file').onchange = () => { const f = $('file').files[0]; if (f) load(null, f); }; | |
| if (q.get('budget')) $('budget').value = q.get('budget'); | |
| await refresh(); | |
| </script> | |
| </body> | |
| </html> | |