llama-cpp-wasm / index.html
lemonteaa
[minor] specify WIP status
ac95d30
Raw History Blame Contribute Delete
44.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llm.js on current llama.cpp — browser demo (WASM, CPU)</title>
<style>
:root { color-scheme: dark; }
body { font: 15px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; background: #14161a; color: #e6e6e6;
max-width: 1080px; margin: 0 auto; padding: 20px 20px 64px; }
h1 { font-size: 19px; font-weight: 600; margin: 0 0 4px; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
color: #9aa1ab; margin: 0 0 10px; }
h3 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
color: #7f8792; margin: 0 0 6px; }
small, .muted, .hint { color: #8a8f98; }
.hint { font-size: 12px; margin: 0 0 10px; }
section.panel { border: 1px solid #2a2f37; border-radius: 10px; margin: 0 0 16px;
padding: 14px 16px; background: #171a1f; }
input[type=text], input[type=password], textarea, select {
width: 100%; box-sizing: border-box; background: #0d0f12; color: #e6e6e6;
border: 1px solid #2a2f37; border-radius: 6px; padding: 8px 10px; font: inherit; }
textarea { min-height: 84px; resize: vertical; }
label.row { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; color: #cfd4da; }
label.row input[type=number] { width: 90px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 6px 8px; font: inherit; }
.buttons { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
button { background: #2f6fed; color: #fff; border: 0; border-radius: 6px; padding: 8px 14px;
font: inherit; cursor: pointer; }
button.secondary { background: #2a2f37; color: #cfd4da; }
button.danger { background: #5a2530; color: #ffb4c0; }
button:disabled { opacity: .45; cursor: default; }
#bar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 999px; overflow: hidden; margin: 10px 0 8px; }
#barFill { height: 100%; width: 0%; background: linear-gradient(90deg, #2f6fed, #5aa0ff); transition: width .15s linear; }
#status { color: #9aa1ab; font-size: 13px; min-height: 20px; }
#out { white-space: pre-wrap; word-break: break-word; background: #0d0f12; border: 1px solid #2a2f37;
border-radius: 6px; padding: 12px; min-height: 140px; }
#stats { color: #8a8f98; font-size: 12px; margin-top: 10px; min-height: 18px; }
details { margin-top: 12px; }
summary { cursor: pointer; color: #9aa1ab; font-size: 13px; }
#log { max-height: 180px; overflow: auto; background: #0d0f12; border: 1px solid #2a2f37; border-radius: 6px;
padding: 8px 10px; margin-top: 8px; font-size: 12px; color: #8a8f98; white-space: pre-wrap; }
.pill { display: inline-block; padding: 1px 7px; border-radius: 999px; background: #23303f; color: #9cc3ff;
font-size: 11px; margin-left: 6px; }
/* --- top bar ---------------------------------------------------------- */
.appbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.appbar label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
.appbar select { flex: 1 1 260px; width: auto; }
.appbar button { padding: 7px 12px; font-size: 13px; }
#currentModel { flex: 1 1 100%; font-size: 12px; }
/* --- tabs ------------------------------------------------------------- */
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 18px 0 0; border-bottom: 1px solid #2a2f37; }
.tab { background: transparent; color: #9aa1ab; border: 1px solid transparent; border-bottom: 0;
border-radius: 8px 8px 0 0; padding: 8px 14px; font-size: 13px; }
.tab.active { background: #171a1f; color: #e6e6e6; border-color: #2a2f37; margin-bottom: -1px; }
.tab:hover { color: #e6e6e6; }
/* --- chat playground -------------------------------------------------- */
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 268px; gap: 16px; align-items: start; margin-top: 16px; }
@media (max-width: 860px) { .workspace { grid-template-columns: minmax(0, 1fr); } }
.col-main { min-width: 0; }
.msg { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 8px; align-items: start;
border: 1px solid #232830; border-radius: 8px; padding: 8px; margin-bottom: 8px; background: #14171c; }
.msg select { padding: 5px 6px; font-size: 12px; }
.msg textarea { min-height: 62px; font-size: 13px; }
.msg .rowbtns { display: flex; flex-direction: column; gap: 4px; }
.msg .rowbtns button { padding: 3px 7px; font-size: 11px; }
.msg.streaming { border-color: #2f6fed; }
.msg.streaming textarea { color: #cfd4da; }
.msg .badge { display: none; }
.msg.streaming .badge { display: inline; color: #7aa9ff; font-size: 11px; margin-left: 6px; }
.msg.streaming select { opacity: .6; }
.pick { width: auto; flex: 0 0 auto; padding: 7px 8px; }
/* --- parameters rail -------------------------------------------------- */
.params { border: 1px solid #2a2f37; border-radius: 10px; padding: 12px 14px; background: #171a1f;
position: sticky; top: 12px; }
.params h2 { margin: 0 0 6px; }
.params h2 + .field { margin-top: 6px; }
.params h2:not(:first-child) { margin-top: 14px; padding-top: 12px; border-top: 1px solid #232830; }
.field { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 7px 0;
color: #bfc5cd; font-size: 12px; }
.field input[type=number] { width: 82px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 5px 7px; font: inherit; font-size: 12px; }
.field.wide { display: block; }
.field.wide textarea { margin-top: 5px; min-height: 58px; font-size: 12px; }
/* --- models tab ------------------------------------------------------- */
.add { border: 1px dashed #2a2f37; border-radius: 8px; padding: 10px 12px 4px; margin-bottom: 10px; }
.addrow { margin-bottom: 8px; }
.addrow.two { display: flex; gap: 8px; flex-wrap: wrap; }
.addrow.two > * { flex: 1 1 220px; min-width: 0; }
input[type=file] { color: #cfd4da; font: inherit; font-size: 13px; }
input[type=file]::file-selector-button { background: #2a2f37; color: #cfd4da; border: 0; border-radius: 6px;
padding: 6px 10px; margin-right: 10px; font: inherit; font-size: 13px; cursor: pointer; }
ul#models { list-style: none; margin: 12px 0 0; padding: 0; }
ul#models li.empty { color: #8a8f98; font-size: 13px; }
ul#models li.model { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 9px 8px; border-top: 1px solid #232830; font-size: 13px; border-radius: 6px; }
ul#models li.model:first-child { border-top: 0; }
ul#models li.model.current { background: #1a2129; }
ul#models input.name { flex: 0 1 200px; background: transparent; border: 1px solid transparent;
border-radius: 6px; padding: 3px 6px; color: #e6e6e6; font: inherit; font-weight: 600; }
ul#models input.name:hover, ul#models input.name:focus { border-color: #2a2f37; background: #0d0f12; }
ul#models .meta { color: #8a8f98; flex: 1 1 190px; }
ul#models .sha { color: #9cc3ff; font-size: 12px; flex: 0 0 auto; }
ul#models .sha.unknown { color: #6d747e; }
ul#models .sha.ok::after { content: " ✓"; color: #7ee2a8; }
ul#models .sha.bad { color: #ff9db0; }
ul#models .sha.bad::after { content: " ≠ expected"; }
ul#models button { padding: 3px 9px; font-size: 12px; }
/* --- template tab ----------------------------------------------------- */
#templateSource { min-height: 170px; font-size: 12px; margin-top: 8px; }
#templatePreview { white-space: pre-wrap; word-break: break-word; background: #0d0f12;
border: 1px solid #2a2f37; border-radius: 6px; padding: 10px; font-size: 12px;
color: #cfd4da; margin: 10px 0 0; max-height: 280px; overflow: auto; }
#templatePreview:empty { display: none; }
/* --- tool calls ------------------------------------------------------- */
.msg .head { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.msg .toolid { font-size: 11px; padding: 3px 5px; }
.msg .toolcalls { grid-column: 1 / -1; margin-top: 4px; }
.tc-head { color: #7aa9ff; font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
margin-bottom: 6px; }
.tc { border: 1px solid #2a2f37; border-radius: 8px; padding: 8px 10px; background: #12151a;
margin-bottom: 6px; }
.tc-title { color: #9cc3ff; font-size: 13px; font-weight: 600; }
.tc-meta { color: #6d747e; font-size: 11px; }
.tc-json { font-size: 12px; margin: 6px 0; }
.tc-json ul { list-style: none; margin: 0; padding-left: 14px; }
.tc-json .k { color: #9aa1ab; }
.tc-json .s { color: #9ad7a0; }
.tc-json .n { color: #ffd28f; }
.tc-json .b { color: #ff9db0; }
</style>
</head>
<body>
<h1>llm.js → latest llama.cpp <span class="pill">wasm32 · CPU · no WebGPU</span> <span class="pill">based on commit 1ab62ba, WIP, expect bugs</span> </h1>
<small>Same <code>LLM</code> API as llm.js, running current llama.cpp. Models live in a local library:
downloaded from a URL or read straight off your disk — never both.</small>
<div class="appbar">
<label for="modelSelect">model</label>
<select id="modelSelect" aria-label="model"></select>
<button id="loadSelected" disabled>Load</button>
<button id="reloadEngine" class="secondary" disabled
title="free the loaded model and the wasm backend, then load it again">Reload engine</button>
<button id="stop" class="danger" disabled title="stop generating, keeping what has arrived">Stop</button>
<div id="currentModel" class="muted"></div>
</div>
<div id="bar"><div id="barFill"></div></div>
<div id="status">idle</div>
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="chat" role="tab" aria-selected="true">Chat playground</button>
<button class="tab" data-tab="completion" role="tab" aria-selected="false">Raw completion</button>
<button class="tab" data-tab="template" role="tab" aria-selected="false">Chat template</button>
<button class="tab" data-tab="models" role="tab" aria-selected="false">Models</button>
</nav>
<main>
<div class="workspace" id="workspace">
<div class="col-main">
<section class="panel" id="panel-chat">
<h2>chat completion</h2>
<p class="hint">This list <em>is</em> the conversation. Generate appends an assistant turn and streams
into it; Stop keeps whatever has already arrived and the turn stays editable.</p>
<div id="messages"></div>
<div class="buttons">
<select id="newRole" class="pick" aria-label="role for the new message">
<option value="user">user</option>
<option value="system">system</option>
<option value="assistant">assistant</option>
<option value="tool">tool</option>
</select>
<button id="addMessage" class="secondary">Add message</button>
<button id="resetConversation" class="secondary">Reset conversation</button>
</div>
<div class="buttons">
<button id="generateChat" disabled>Generate</button>
<button id="exportJson" class="secondary">Export JSON</button>
<button id="exportMd" class="secondary">Export Markdown</button>
</div>
</section>
<section class="panel" id="panel-completion" hidden>
<h2>raw completion</h2>
<p class="hint">No chat template: this text is the prompt, tokenized as typed. Completion models
usually expect a trailing space.</p>
<textarea id="prompt" class="prompt">Explain in two sentences why WebAssembly is useful for running an LLM in a browser.</textarea>
<div class="buttons">
<button id="run" disabled>Generate</button>
<button id="clear" class="secondary">Clear output</button>
</div>
<div id="out" class="out"></div>
</section>
<div id="stats"></div>
</div>
<aside class="params" id="paramsRail">
<h2>sampling</h2>
<label class="field">temperature <input type="number" id="temp" value="0.7" step="0.05" min="0" max="2"></label>
<label class="field">top_p <input type="number" id="topP" value="0.9" step="0.05" min="0" max="1"></label>
<label class="field">top_k <input type="number" id="topK" value="40" step="1" min="0"></label>
<label class="field">repetition penalty <input type="number" id="repeatPenalty" value="1.1" step="0.05" min="0" max="3"></label>
<label class="field">penalty window <input type="number" id="repeatLastN" value="32" step="1" min="0"></label>
<label class="field">seed <input type="number" id="seed" value="-1" step="1" title="-1 = random"></label>
<h2>limits</h2>
<label class="field">max new tokens <input type="number" id="maxTokens" value="96" step="1" min="1" max="2048"></label>
<label class="field wide">stop sequences
<textarea id="stopSequences" spellcheck="false" placeholder="one per line (&#92;n and &#92;t are expanded)"></textarea>
</label>
<h2>tools</h2>
<label class="field wide">tool definitions (OpenAI JSON)
<textarea id="tools" spellcheck="false" placeholder='[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]'></textarea>
</label>
<p class="hint" style="margin-top:8px">Handed to the chat template. A reply that calls a tool is
parsed by the model's own parser and shown as JSON next to the assistant turn.</p>
<h2>thinking</h2>
<label class="row"><input type="checkbox" id="enableThinking"> enable_thinking</label>
<p class="hint" style="margin-top:6px">Writes the <code>enable_thinking</code> template parameter into
the parameters JSON on the Chat template tab.</p>
</aside>
</div>
<section class="panel" id="panel-template" hidden>
<h2>chat template</h2>
<p class="hint" id="templateInfo"></p>
<label class="row"><input type="checkbox" id="useModelTemplate" checked>
use the template shipped in the GGUF (Jinja)</label>
<textarea id="templateSource" spellcheck="false" placeholder="Jinja chat template source"></textarea>
<label class="field wide" style="margin-top:12px">template parameters (JSON)
<textarea id="templateKwargs" spellcheck="false"
placeholder='{"enable_thinking": true}'></textarea>
</label>
<p class="hint" style="margin-top:8px">Passed to the Jinja template as extra variables, e.g.
<code>enable_thinking</code> for MiniCPM / Qwen-style templates.</p>
<div class="buttons">
<button id="previewTemplate" class="secondary">Preview with the playground messages</button>
<button id="resetTemplate" class="secondary">Reset to the model's template</button>
</div>
<pre id="templatePreview"></pre>
</section>
<section class="panel" id="panel-models" hidden>
<h2>model library</h2>
<div class="add">
<h3>from a URL</h3>
<div class="addrow">
<input type="text" id="modelUrl"
value="https://huggingface.co/LiquidAI/LFM2.5-VL-450M-GGUF/resolve/main/LFM2.5-VL-450M-Q4_K_M.gguf">
</div>
<div class="addrow two">
<input type="text" id="urlName" placeholder="display name (optional — defaults to the file name)">
<input type="text" id="expectedSha" placeholder="expected sha256 (optional, to check against the publisher)">
</div>
<div class="buttons">
<button id="addUrl">Download &amp; load</button>
<button id="pause" class="secondary" disabled>Pause</button>
<button id="resume" class="secondary" disabled>Resume</button>
</div>
<p class="hint">Downloaded in 4 MiB chunks into IndexedDB: pausable, resumable, deletable.</p>
</div>
<div class="add">
<h3>from this computer</h3>
<div class="addrow two">
<input type="file" id="fileInput" accept=".gguf,application/octet-stream">
<input type="text" id="fileLabel" placeholder="display name (optional — defaults to the file name)">
</div>
<label class="row"><input type="checkbox" id="keepCopy">
keep a copy in browser storage</label>
<p class="hint">Left unchecked the file is read in 4 MiB slices at load time and never duplicated —
you only re-pick it after a reload. Checked, a copy is written to IndexedDB (one copy in total)
so the model survives a reload on its own.</p>
<div class="buttons">
<button id="addFile" disabled>Add &amp; load</button>
</div>
</div>
<div class="muted" id="quota" style="font-size:12px;margin-top:6px"></div>
<ul id="models"><li class="empty">no models yet — add one above</li></ul>
<details>
<summary>llama.cpp log</summary>
<div id="log"></div>
</details>
</section>
</main>
<script type="module">
import { LLM, MODELS } from "./llm.js";
import { action } from "./actions.js";
import { listModels, deleteModel, updateModel, storageEstimate } from "./storage.js";
const N_CTX = 512;
const $ = (id) => document.getElementById(id);
const fmt = (bytes) => {
if (!bytes && bytes !== 0) return "?";
const mib = bytes / 1048576;
return mib >= 1024 ? (mib / 1024).toFixed(2) + " GiB" : mib.toFixed(1) + " MiB";
};
const pct = (loaded, total) => (total ? Math.min(100, (loaded / total) * 100) : 0);
const statusText = (t) => { $("status").textContent = t; };
const log = (t) => {
const el = $("log");
el.textContent += t.endsWith("\n") ? t : t + "\n";
el.scrollTop = el.scrollHeight;
};
const setBar = (loaded, total) => { $("barFill").style.width = pct(loaded, total).toFixed(1) + "%"; };
let app = null;
let models = [];
let loadedId = null; // model currently in the wasm instance
let currentId = null; // model the user last asked to load
let pendingFile = null; // file picked for the "add" form
let reattachFor = null; // model id waiting for its file to be re-picked
const attached = new Map(); // model id -> File, this session only
let uiState = "idle";
let loadedTemplate = ""; // the Jinja source shipped in the loaded GGUF
let chatRow = null; // the assistant turn currently being streamed into
const BUSY = ["starting", "downloading", "copying", "hashing", "loading", "generating"];
// --- tabs -----------------------------------------------------------------
function showTab(name) {
for (const btn of document.querySelectorAll(".tab")) {
const on = btn.dataset.tab === name;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
}
const running = name === "chat" || name === "completion";
$("workspace").hidden = !running;
$("panel-chat").hidden = name !== "chat";
$("panel-completion").hidden = name !== "completion";
$("panel-template").hidden = name !== "template";
$("panel-models").hidden = name !== "models";
}
for (const btn of document.querySelectorAll(".tab")) {
btn.onclick = () => showTab(btn.dataset.tab);
}
function setState(state) {
uiState = state;
const busy = BUSY.includes(state);
$("addUrl").disabled = busy;
$("addFile").disabled = busy || !pendingFile;
$("pause").disabled = state !== "downloading";
$("resume").disabled = state !== "paused";
$("generateChat").disabled = busy || !loadedId;
$("run").disabled = busy || !loadedId;
$("stop").disabled = state !== "generating";
$("modelUrl").disabled = busy;
$("loadSelected").disabled = busy || !models.length;
$("reloadEngine").disabled = busy || !loadedId;
}
// --- parameters -----------------------------------------------------------
const num = (id, fallback) => {
const v = Number($(id).value);
return Number.isFinite(v) ? v : fallback;
};
// one per line; \n, \t and \r let a stop sequence span lines
function stopList() {
return $("stopSequences").value.split("\n")
.filter((line) => line.length > 0)
.map((line) => line.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, "\r"));
}
function templateOverride() {
return $("useModelTemplate").checked ? "" : $("templateSource").value;
}
function runParams() {
return {
max_token_len: Math.max(1, num("maxTokens", 96)),
temp: num("temp", 0.7),
top_p: num("topP", 0.9),
top_k: num("topK", 40),
repeat_penalty: num("repeatPenalty", 1.1),
repeat_last_n: num("repeatLastN", 32),
seed: num("seed", -1),
stop: stopList(),
context_size: N_CTX,
chat_template_source: templateOverride(),
tools: $("tools").value.trim(),
template_kwargs: $("templateKwargs").value.trim() || "{}",
};
}
// --- worker plumbing ------------------------------------------------------
function ensureApp() {
if (app) return app;
app = new LLM(
MODELS.GGUF_CPU,
{}, // no model yet: loads are driven by id
(info) => {
loadedId = info.model.id;
currentId = info.model.id;
const m = info.model;
loadedTemplate = info.chat_template || "";
$("currentModel").textContent = `${m.name}` + (m.sha256 ? ` · sha256 ${m.sha256.slice(0, 12)}…` : "");
$("templateSource").value = m.templateOverride || loadedTemplate;
$("useModelTemplate").checked = !m.templateOverride;
syncTemplateUI();
statusText(`model ready · ${m.name} · n_ctx=${info.n_ctx} · ` +
(loadedTemplate ? "chat template from the GGUF" : "no chat template"));
setBar(1, 1);
setState("ready");
refreshModels();
},
// tokens: into the streaming assistant turn, or into the completion box
(text) => {
if (chatRow) {
const ta = chatRow.querySelector(".content");
ta.value += text;
ta.scrollTop = ta.scrollHeight;
} else {
$("out").textContent += text;
}
},
(stats) => {
if (stats.mode === "chat" && chatRow) {
// the final text is authoritative: it has stop sequences removed and
// any held-back tail flushed
const ta = chatRow.querySelector(".content");
ta.value = stats.output ?? ta.value;
ta.readOnly = false;
chatRow.classList.remove("streaming");
// tool calls are structured data: the parser built for this prompt
// already split them out, so show them as JSON, not as text
const calls = stats.parsed && stats.parsed.tool_calls;
if (Array.isArray(calls) && calls.length) {
// the call markup itself is not transcript text: what the model said
// before calling is, and the call is shown as JSON instead
const said = stats.parsed.content;
ta.value = typeof said === "string" ? said.trimEnd() : ta.value;
chatRow.toolCalls = openAiToolCalls(calls);
renderToolCalls(chatRow, calls);
}
chatRow = null;
} else if (stats.mode !== "chat") {
$("out").textContent = stats.output ?? $("out").textContent;
}
const bits = [`${stats.tokens} tokens in ${(stats.ms / 1000).toFixed(2)}s ` +
`(${stats.tokens_per_second.toFixed(2)} tok/s)`];
if (stats.template_strategy) bits.push(`template: ${stats.template_strategy}`);
if (stats.interrupted) bits.push("stopped");
$("stats").textContent = bits.join(" · ");
setState("ready");
},
(err) => {
if (chatRow) {
chatRow.classList.remove("streaming");
chatRow.querySelector(".content").readOnly = false;
chatRow = null;
}
statusText("error: " + err);
log("ERROR: " + err);
setState(loadedId ? "ready" : "idle");
},
(p) => {
if (p.phase === "download") {
setState("downloading");
statusText(p.complete ? "downloaded, preparing…"
: `downloading ${fmt(p.loaded)} / ${fmt(p.total)} (${pct(p.loaded, p.total).toFixed(1)}%)`);
setBar(p.loaded, p.total);
} else if (p.phase === "copy") {
setState("copying");
statusText(`copying into browser storage ${fmt(p.loaded)} / ${fmt(p.total)}`);
setBar(p.loaded, p.total);
} else if (p.phase === "hash") {
setState("hashing");
statusText(`sha256 ${pct(p.loaded, p.total).toFixed(0)}% of ${fmt(p.total)}`);
setBar(p.loaded, p.total);
} else if (p.phase === "load") {
setState("loading");
statusText(`loading into wasm ${fmt(p.loaded)} / ${fmt(p.total)}`);
setBar(p.loaded, p.total);
}
},
(line) => log(line.trimEnd()),
);
app.on_event = (msg) => {
switch (msg.event) {
case action.MODEL_ADDED:
if (pendingFile && msg.model.source === "file") attached.set(msg.model.id, pendingFile);
pendingFile = null;
$("fileInput").value = "";
$("fileLabel").value = "";
refreshModels();
break;
case action.MODEL_UPDATED:
refreshModels();
break;
case action.PAUSED:
statusText("paused — progress is saved, press Resume to continue");
setState("paused");
refreshModels();
break;
case action.TEMPLATE_PREVIEW:
$("templatePreview").textContent = msg.text;
break;
case action.DELETED:
if (loadedId === msg.id) {
loadedId = null;
loadedTemplate = "";
$("currentModel").textContent = "";
$("templateSource").value = "";
syncTemplateUI();
}
if (currentId === msg.id) currentId = null;
attached.delete(msg.id);
statusText("deleted from the library");
refreshModels();
setState(loadedId ? "ready" : "idle");
break;
}
};
app.load_worker({ context_size: N_CTX, autoload: false });
return app;
}
// --- conversation ---------------------------------------------------------
const ROLES = ["system", "user", "assistant", "tool"];
function addMessageRow(role = "user", content = "", { streaming = false } = {}) {
const row = document.createElement("div");
row.className = "msg" + (streaming ? " streaming" : "");
row.toolCalls = null;
const sel = document.createElement("select");
sel.className = "role";
sel.setAttribute("aria-label", "role");
for (const r of ROLES) sel.appendChild(new Option(r, r));
sel.value = role;
// a tool result names the call it answers; every other role hides this
const toolid = document.createElement("input");
toolid.type = "text";
toolid.className = "toolid";
toolid.placeholder = "tool_call_id";
toolid.title = "which tool call this result answers";
toolid.hidden = role !== "tool";
sel.onchange = () => { toolid.hidden = sel.value !== "tool"; };
const ta = document.createElement("textarea");
ta.className = "content";
ta.spellcheck = false;
ta.value = content;
ta.readOnly = streaming;
const tools = document.createElement("div");
tools.className = "rowbtns";
for (const [act, label, title] of [["up", "↑", "move up"], ["down", "↓", "move down"], ["del", "✕", "remove"]]) {
const b = document.createElement("button");
b.className = act === "del" ? "danger" : "secondary";
b.dataset.act = act;
b.textContent = label;
b.title = title;
tools.appendChild(b);
}
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = "streaming…";
const head = document.createElement("div");
head.className = "head";
head.append(sel, toolid, badge);
row.append(head, ta, tools);
$("messages").appendChild(row);
row.scrollIntoView({ block: "nearest" });
return row;
}
$("messages").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-act]");
if (!btn) return;
const row = btn.closest(".msg");
const act = btn.dataset.act;
if (act === "del") row.remove();
if (act === "up" && row.previousElementSibling) $("messages").insertBefore(row, row.previousElementSibling);
if (act === "down" && row.nextElementSibling) $("messages").insertBefore(row.nextElementSibling, row);
});
function readMessages() {
return [...$("messages").querySelectorAll(".msg")]
.map((row) => {
const role = row.querySelector(".role").value;
const content = row.querySelector(".content").value;
if (role === "tool") {
const id = (row.querySelector(".toolid")?.value || "").trim();
return id ? { role, content, tool_call_id: id } : { role, content };
}
if (row.toolCalls && row.toolCalls.length) {
return { role, content, tool_calls: row.toolCalls };
}
return { role, content };
})
.filter((m) => m.content.length > 0 || (m.tool_calls && m.tool_calls.length));
}
$("addMessage").onclick = () => addMessageRow($("newRole").value, "");
$("resetConversation").onclick = () => {
$("messages").innerHTML = "";
addMessageRow("user", "");
$("stats").textContent = "";
};
addMessageRow("user", "Explain in two sentences why WebAssembly is useful for running an LLM in a browser.");
// --- run ------------------------------------------------------------------
$("generateChat").onclick = () => {
if (!app) return;
const messages = readMessages();
if (!messages.length) { statusText("add a message first"); return; }
// the assistant turn is part of the transcript before the first token
chatRow = addMessageRow("assistant", "", { streaming: true });
$("stats").textContent = "generating…";
setState("generating");
app.run({ mode: "chat", messages, ...runParams() });
};
$("run").onclick = () => {
if (!app) return;
$("out").textContent = "";
$("stats").textContent = "generating…";
setState("generating");
app.run({ mode: "completion", prompt: $("prompt").value, ...runParams() });
};
$("stop").onclick = () => {
if (!app) return;
statusText("stopping — what arrived stays");
app.stop();
};
$("clear").onclick = () => { $("out").textContent = ""; $("stats").textContent = ""; };
// --- model library --------------------------------------------------------
const button = (label, cls, onClick) => {
const b = document.createElement("button");
b.textContent = label;
b.className = cls;
b.onclick = onClick;
return b;
};
function renderRow(m) {
const li = document.createElement("li");
li.className = "model" + (m.id === loadedId ? " current" : "");
li.dataset.id = m.id;
const name = document.createElement("input");
name.className = "name";
name.value = m.name;
name.title = "display name — edit, then press Enter";
name.onchange = async () => {
const value = name.value.trim();
if (!value || value === m.name) { name.value = m.name; return; }
await updateModel(m.id, { name: value });
log(`renamed ${m.name} → ${value}`);
refreshModels();
};
const meta = document.createElement("span");
meta.className = "meta";
const bits = [m.source === "url" ? "url" : "local file", fmt(m.total || m.received || 0)];
if (m.source === "url" && !m.complete) bits.push("partial");
if (m.source === "file") {
bits.push(m.copied ? "copy in browser" : attached.has(m.id) ? "file attached" : "needs file");
}
meta.textContent = bits.join(" · ");
meta.title = m.url || m.fileName || "";
const sha = document.createElement("span");
sha.className = "sha";
if (m.sha256) {
sha.textContent = `sha256 ${m.sha256.slice(0, 16)}…`;
sha.title = m.sha256;
if (m.expectedSha256) sha.classList.add(m.expectedSha256 === m.sha256 ? "ok" : "bad");
} else {
sha.textContent = "sha256 not computed";
sha.classList.add("unknown");
}
li.append(name, meta, sha);
li.appendChild(button("load", "", () => startLoad(m)));
if (m.sha256) li.appendChild(button("copy hash", "secondary", () => copyHash(m)));
li.appendChild(button("delete", "danger", () => removeModel(m)));
return li;
}
async function refreshModels() {
models = await listModels().catch(() => []);
const ul = $("models");
ul.innerHTML = "";
if (!models.length) {
ul.innerHTML = '<li class="empty">no models yet — add one above</li>';
} else {
for (const m of models) ul.appendChild(renderRow(m));
}
const select = $("modelSelect");
const wanted = currentId || loadedId || "";
select.innerHTML = "";
if (!models.length) select.appendChild(new Option("no models yet", ""));
for (const m of models) {
const needsFile = m.source === "file" && !m.copied && !attached.has(m.id);
const label = `${m.name} · ${m.source === "url" ? "url" : "file"} · ` +
`${fmt(m.total || m.received || 0)}` +
(m.id === loadedId ? " · loaded" : needsFile ? " · needs file" : "");
select.appendChild(new Option(label, m.id));
}
select.value = wanted;
const est = await storageEstimate();
if (est && est.quota) {
$("quota").textContent = `browser storage: ${fmt(est.usage || 0)} used of ${fmt(est.quota)}` +
(models.length ? ` · ${fmt(models.reduce((n, m) => n + (m.source === "url" || m.copied ? m.received || 0 : 0), 0))} in this library` : "");
}
}
async function copyHash(m) {
try {
await navigator.clipboard.writeText(m.sha256);
statusText(`copied sha256 of ${m.name} to the clipboard`);
} catch (_) {
log(`sha256(${m.name}) = ${m.sha256}`);
statusText("clipboard unavailable — the hash is in the log");
}
}
async function removeModel(m) {
if (app) app.post({ event: action.DELETE_MODEL, id: m.id });
else { await deleteModel(m.id); refreshModels(); }
}
function selectedModel() {
const id = $("modelSelect").value;
return models.find((m) => m.id === id) || null;
}
// --- actions --------------------------------------------------------------
function startLoad(m) {
// a local file that was never copied needs its File again after a reload
if (m.source === "file" && !m.copied && !attached.has(m.id)) {
reattachFor = m.id;
statusText(`choose ${m.fileName} again — nothing is re-downloaded, the file stays on your disk`);
$("fileInput").click();
return;
}
ensureApp();
currentId = m.id;
$("out").textContent = "";
$("stats").textContent = "";
statusText("starting…");
setState("starting");
app.post({
event: action.LOAD,
id: m.id,
file: attached.get(m.id) || null,
context_size: N_CTX,
});
}
$("loadSelected").onclick = () => {
const m = selectedModel();
if (m) startLoad(m);
};
$("reloadEngine").onclick = () => {
if (!app || !currentId) return;
$("out").textContent = "";
$("stats").textContent = "";
statusText("freeing the model and reloading the engine…");
setState("starting");
app.reload(currentId);
};
$("fileInput").onchange = () => {
const file = $("fileInput").files[0];
if (!file) return;
if (reattachFor) {
const id = reattachFor;
reattachFor = null;
const m = models.find((x) => x.id === id);
if (m && file.size !== m.total) log(`note: ${file.name} is ${fmt(file.size)}, the record says ${fmt(m.total)}`);
attached.set(id, file);
log(`re-attached ${file.name}`);
if (m) startLoad(m);
return;
}
pendingFile = file;
if (!$("fileLabel").value) $("fileLabel").placeholder = file.name;
statusText(`${file.name} · ${fmt(file.size)} — press "Add & load"`);
setState(uiState);
};
$("addFile").onclick = () => {
const file = pendingFile || $("fileInput").files[0];
if (!file) return;
ensureApp();
$("out").textContent = "";
$("stats").textContent = "";
statusText("starting…");
setState("starting");
app.post({
event: action.ADD_FILE,
file,
name: $("fileLabel").value.trim(),
keepCopy: $("keepCopy").checked,
expectedSha256: $("expectedSha").value.trim().toLowerCase(),
context_size: N_CTX,
});
};
$("addUrl").onclick = () => {
const url = $("modelUrl").value.trim();
if (!url) return;
ensureApp();
$("out").textContent = "";
$("stats").textContent = "";
statusText("starting…");
setState("starting");
app.post({
event: action.ADD_URL,
url,
name: $("urlName").value.trim(),
expectedSha256: $("expectedSha").value.trim().toLowerCase(),
context_size: N_CTX,
});
};
$("pause").onclick = () => {
if (app) { app.pause(); statusText("pausing…"); }
};
$("resume").onclick = () => {
if (!app) return;
statusText("resuming…");
setState("downloading");
app.resume();
};
// --- chat template tab ----------------------------------------------------
function syncTemplateUI() {
const useModel = $("useModelTemplate").checked;
$("templateSource").readOnly = useModel;
$("templateSource").style.opacity = useModel ? 0.7 : 1;
$("templateInfo").textContent = useModel
? (loadedTemplate
? "chat completion renders the template shipped in this GGUF, through the Jinja engine compiled into the wasm build"
: "this model carries no chat template — chat completion falls back to a preset, then to plain text")
: "chat completion renders your override; the model's own template is ignored";
$("resetTemplate").disabled = !loadedTemplate;
}
$("useModelTemplate").onchange = () => {
if ($("useModelTemplate").checked && loadedTemplate) $("templateSource").value = loadedTemplate;
syncTemplateUI();
};
$("resetTemplate").onclick = () => {
$("templateSource").value = loadedTemplate;
$("useModelTemplate").checked = true;
syncTemplateUI();
};
$("previewTemplate").onclick = () => {
if (!app) { statusText("load a model first"); return; }
$("templatePreview").textContent = "rendering…";
app.preview_template({
messages: readMessages(),
prompt: $("prompt").value,
template_source: templateOverride(),
tools: $("tools").value.trim(),
template_kwargs: $("templateKwargs").value.trim() || "{}",
});
};
// --- tool calls, template params, conversation export ---------------------
function openAiToolCalls(calls) {
return calls.map((c, i) => ({
id: c.id || `call_${i}`,
type: "function",
function: {
name: c.name,
arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
},
}));
}
// small local JSON tree: no CDN, the lab keeps working offline
function renderJson(value, parent) {
const leaf = (text, cls) => {
const s = document.createElement("span");
s.className = cls;
s.textContent = text;
parent.appendChild(s);
};
if (value === null) { leaf("null", "b"); return; }
if (Array.isArray(value)) {
const list = document.createElement("ul");
value.forEach((v, i) => {
const li = document.createElement("li");
const key = document.createElement("span");
key.className = "k";
key.textContent = `${i}: `;
li.appendChild(key);
renderJson(v, li);
list.appendChild(li);
});
parent.appendChild(list);
return;
}
if (typeof value === "object") {
const list = document.createElement("ul");
for (const [k, v] of Object.entries(value)) {
const li = document.createElement("li");
const key = document.createElement("span");
key.className = "k";
key.textContent = `${k}: `;
li.appendChild(key);
renderJson(v, li);
list.appendChild(li);
}
parent.appendChild(list);
return;
}
if (typeof value === "string") leaf(`"${value}"`, "s");
else if (typeof value === "number") leaf(String(value), "n");
else leaf(String(value), "b");
}
function renderToolCalls(row, calls) {
const box = document.createElement("div");
box.className = "toolcalls";
const head = document.createElement("div");
head.className = "tc-head";
head.textContent = `${calls.length} tool call${calls.length > 1 ? "s" : ""} parsed by the model's parser`;
box.appendChild(head);
for (const call of calls) {
const card = document.createElement("div");
card.className = "tc";
const title = document.createElement("div");
title.className = "tc-title";
title.textContent = call.name;
const meta = document.createElement("div");
meta.className = "tc-meta";
meta.textContent = call.id || "no id from the template";
const tree = document.createElement("div");
tree.className = "tc-json";
renderJson(call.arguments, tree);
const add = document.createElement("button");
add.className = "secondary";
add.textContent = "Add tool response";
add.onclick = () => {
const toolRow = addMessageRow("tool", "");
if (call.id) toolRow.querySelector(".toolid").value = call.id;
toolRow.querySelector(".content").focus();
toolRow.scrollIntoView({ block: "nearest" });
};
card.append(title, meta, tree, add);
box.appendChild(card);
}
row.appendChild(box);
}
// --- exports --------------------------------------------------------------
function download(name, text, type) {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
function toMarkdown(messages) {
const out = [];
for (const m of messages) {
const suffix = m.role === "tool" && m.tool_call_id ? ` (${m.tool_call_id})` : "";
out.push(`### ${m.role.toUpperCase()}${suffix}\n`);
if (m.content) out.push(m.content.trimEnd() + "\n");
if (m.tool_calls && m.tool_calls.length) {
out.push("**Tool calls**\n");
out.push("```json\n" + JSON.stringify(m.tool_calls, null, 2) + "\n```\n");
}
out.push("");
}
return out.join("\n");
}
$("exportJson").onclick = () => {
const messages = readMessages();
if (!messages.length) { statusText("nothing to export"); return; }
download(`conversation-${stamp()}.json`, JSON.stringify(messages, null, 2), "application/json");
statusText(`exported ${messages.length} messages as JSON`);
};
$("exportMd").onclick = () => {
const messages = readMessages();
if (!messages.length) { statusText("nothing to export"); return; }
download(`conversation-${stamp()}.md`, toMarkdown(messages), "text/markdown");
statusText(`exported ${messages.length} messages as Markdown`);
};
// --- static template parameters ------------------------------------------
function readKwargs() {
try {
const kw = JSON.parse($("templateKwargs").value || "{}");
return kw && typeof kw === "object" && !Array.isArray(kw) ? kw : {};
} catch (_) {
return {};
}
}
$("enableThinking").onchange = () => {
const kw = readKwargs();
if ($("enableThinking").checked) kw.enable_thinking = true;
else delete kw.enable_thinking;
$("templateKwargs").value = JSON.stringify(kw, null, 2);
};
$("templateKwargs").value = "{}";
setState("idle");
refreshModels();
</script>
</body>
</html>