diskformer-chat / index.html
naklitechie's picture
Sync from GitHub
f5c3925 verified
Raw History Blame Contribute Delete
23.6 kB
<!doctype html>
<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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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>