Spaces:
Running
Running
Download index.html from lemonteaa/llama-cpp-wasm: direct link, hf CLI and curl.
- Browser
- Download file 80.5 kB
-
https://huggingface.co/spaces/lemonteaa/llama-cpp-wasm/resolve/main/index.html
- Command line
-
hf download hf://spaces/lemonteaa/llama-cpp-wasm/index.html
-
curl -L -o index.html https://huggingface.co/spaces/lemonteaa/llama-cpp-wasm/resolve/main/index.html
80.5 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; }
/* .workspace/.panel set display:grid/flex, which beats the UA [hidden] rule:
without this the sidebar stayed visible on the template and model tabs and
pushed their panel below it. */
[hidden] { display: none !important; }
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; }
/* --- model ingest (Models tab) ---------------------------------------- */
/* separate from the engine bar on purpose: getting bytes into the library
and starting a context are different operations with different outcomes */
#ingest { border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f;
padding: 9px 11px; margin: 0 0 12px; }
#ingest.error { border-color: #5a2530; }
.ingest-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
font-size: 12px; color: #cfd4da; margin-bottom: 7px; }
#ingestPercent { color: #8a8f98; font-size: 11px; }
#ingestBar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37;
border-radius: 999px; overflow: hidden; }
#ingestFill { height: 100%; width: 0%; background: linear-gradient(90deg, #6752c8, #a98bff);
transition: width .15s linear; }
#ingest.error #ingestFill { background: #c05050; }
#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; }
#log .logline { display: block; }
#log .warn { color: #d8b25f; }
#log .err { color: #e07070; }
.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; }
/* a flex item refuses to shrink below its longest option, so a long model name
would push the whole page sideways on a phone; min-width lets it ellipsize */
.appbar select { flex: 1 1 260px; width: auto; min-width: 0; max-width: 100%; }
.appbar button { padding: 7px 12px; font-size: 13px; }
#currentModel { flex: 1 1 100%; font-size: 12px; }
/* --- engine launch row (top bar) -------------------------------------- */
.enginebar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: 8px;
padding: 7px 10px; border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f; }
.enginebar-title { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
.ef { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #bfc5cd; white-space: nowrap; }
.ef input[type=number] { width: 66px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
.ef select { width: auto; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 3px 5px; font: inherit; font-size: 12px; }
.enginebar #mmprojSelect { width: 118px; font-size: 12px; }
.ef.check { gap: 5px; }
.ef input[type=checkbox] { margin: 0; }
#threadsHint { flex: 1 1 100%; margin: 0; font-size: 11px; }
.enginebar details { flex: 1 1 100%; margin: 0; }
.enginebar summary { font-size: 11px; }
.enginebar details p { margin: 6px 0 0; }
.enginebar .hint { margin: 0; }
/* --- 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 .thumbs { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; }
.msg .thumbs:empty { display: none; }
.thumb { position: relative; }
.thumb img { display: block; width: 64px; height: 64px; object-fit: cover;
border-radius: 6px; border: 1px solid #2a2f37; }
.thumb button { position: absolute; top: -6px; right: -6px; padding: 0; width: 18px; height: 18px;
line-height: 1; border-radius: 999px; font-size: 11px; }
.msg.dragover { border-color: #5aa0ff; }
ul#models .mm { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #8a8f98; }
#mmprojSelect { width: 148px; font-size: 11px; }
#mmprojSelect:disabled, .field select:disabled { opacity: .45; }
.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 select { width: 96px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 5px 6px; 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; }
.cards { display: grid; gap: 10px; margin-top: 12px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.card { display: flex; flex-direction: column; gap: 8px; padding: 12px;
border: 1px solid #2a2f37; border-radius: 8px; background: #12161b; }
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.card-head strong { font-size: 14px; }
.card-link { color: #9cc3ff; font-size: 12px; text-decoration: none; white-space: nowrap; }
.card-link:hover { text-decoration: underline; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font-size: 11px; color: #8a8f98; border: 1px solid #2a2f37;
border-radius: 999px; padding: 1px 8px; }
.chip.on { color: #7ee2a8; border-color: #2b4436; }
.card-summary { margin: 0; font-size: 13px; color: #cfd4da; }
.card .hint { margin: 0; }
.card .buttons { margin-top: auto; }
/* --- 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; }
.tc-raw { margin: 6px 0 0; padding: 8px 10px; background: #0d0f12; border: 1px solid #2a2f37;
border-radius: 6px; font-size: 12px; color: #cfd4da; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<h1>llm.js → latest llama.cpp <span class="pill">wasm32 · CPU · no WebGPU</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 class="enginebar" role="group" aria-label="engine launch parameters">
<span class="enginebar-title">engine <span class="pill" title="fixed when the context is created">launch</span></span>
<label class="ef" title="context length in tokens; KV memory grows with this">ctx
<input type="number" id="ctxSize" value="1024" step="256" min="128" max="16384"
aria-label="context length"></label>
<label class="ef">KV K <select id="kvTypeK" aria-label="KV cache type for K">
<option value="f16" selected>f16</option>
<option value="q8_0">q8_0</option>
<option value="q4_0">q4_0</option>
</select></label>
<label class="ef">KV V <select id="kvTypeV" aria-label="KV cache type for V">
<option value="f16" selected>f16</option>
<option value="q8_0">q8_0</option>
<option value="q4_0">q4_0</option>
</select></label>
<label class="ef">flash <select id="flashAttn" aria-label="flash attention">
<option value="-1" selected>auto</option>
<option value="0">off</option>
<option value="1">on</option>
</select></label>
<label class="ef">threads <input type="number" id="threads" min="1" max="16" step="1" value="1"
aria-label="CPU threads"></label>
<label class="ef check"><input type="checkbox" id="kvUnified" checked> unified KV</label>
<label class="ef check"><input type="checkbox" id="useMmproj"> mmproj</label>
<label class="ef">projector <select id="mmprojSelect" aria-label="multimodal projector" disabled></select></label>
<p class="hint" id="threadsHint"></p>
<details>
<summary>how launch settings apply</summary>
<p class="hint">Applied when a model loads — press <b>Reload engine</b> after changing them.
One parallel sequence: extra slots would only duplicate a cache a single user cannot fill.
A quantized V cache needs flash attention; asking for one switches it on. KV memory grows
with context length.</p>
<p class="hint">Flag a GGUF as <b>mmproj</b> in the model library to choose it here. Then attach
images to a message (button or drag & drop); the image goes into the prompt as the
projector's media marker.</p>
</details>
</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="recommended" role="tab" aria-selected="false">Recommended</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>
<input type="file" id="imageInput" accept="image/*" multiple hidden>
</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 (\n and \t are expanded)"></textarea>
</label>
<h2>constrained output</h2>
<label class="field wide">constrain the reply with
<select id="constrain">
<option value="off">nothing (free text)</option>
<option value="tools" selected>tool calls (definitions below)</option>
<option value="json_schema">JSON schema</option>
<option value="grammar">GBNF grammar</option>
</select>
</label>
<label class="field wide" id="toolsWrap">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>
<label class="field wide" id="schemaWrap" hidden>JSON schema
<textarea id="jsonSchema" spellcheck="false" placeholder='{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}'></textarea>
</label>
<label class="field wide" id="grammarWrap" hidden>GBNF grammar
<textarea id="grammar" spellcheck="false" placeholder='root ::= "yes" | "no"'></textarea>
</label>
<p class="hint" style="margin-top:8px">Tool definitions go to the chat template and the reply
is parsed by the model's own parser. A JSON schema or grammar instead constrains the reply
text itself, and replaces any grammar the template would have used. One constraint at a time.</p>
<h2>template</h2>
<label class="field wide">extra template arguments (JSON)
<textarea id="runTemplateKwargs" spellcheck="false"
placeholder='{"enable_thinking": true}'></textarea>
</label>
<label class="row"><input type="checkbox" id="enableThinking"> enable_thinking</label>
<p class="hint" style="margin-top:6px">The same object as <em>parameters JSON</em> on the Chat template
tab: edit it in either place — preview and generation both use it for the next turn.
<em>enable_thinking</em> only sets the template variable; whether reasoning is emitted and
separated into <code>reasoning_content</code> depends on the model's template and parser.</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>
<p class="hint" style="margin-top:6px">Messages with images render one media marker per
image (<code><__media__></code>), in attachment order; that marker is what the projector
replaces with image tokens at generation time, so the preview shows the marker itself.</p>
</section>
<section class="panel" id="panel-recommended" hidden>
<h2>recommended models</h2>
<p class="hint">Driven by <code>web/models/recommended.json</code> — a plain static file, so this list can
change without rebuilding the page. Downloads take the same route as the Models tab (4 MiB chunks
into IndexedDB, resumable, with the publisher's sha256 checked on the way in).</p>
<div id="recList" class="cards"><p class="muted">loading…</p></div>
</section>
<section class="panel" id="panel-models" hidden>
<h2>model library</h2>
<div id="ingest" hidden>
<div class="ingest-head"><span id="ingestLabel">preparing…</span><span id="ingestPercent"></span></div>
<div id="ingestBar"><div id="ingestFill"></div></div>
</div>
<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>
<label class="row"><input type="checkbox" id="urlMmproj">
this file is a multimodal projector (mmproj)</label>
<div class="buttons">
<button id="addUrl">Download & 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>
<label class="row"><input type="checkbox" id="fileMmproj">
this file is a multimodal projector (mmproj)</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 & 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 DEFAULT_CTX = 1024;
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; };
// llama.cpp log levels: 1 debug, 2 info, 3 warn, 4 error, 5 continuation.
// Only warn/error are coloured; info is llama.cpp's own progress chatter
// (every image batch logs one line) and must not read as a failure.
const log = (t, level = 0) => {
const el = $("log");
const text = t.endsWith("\n") ? t : t + "\n";
if (level >= 3) {
const line = document.createElement("span");
line.className = "logline " + (level >= 4 ? "err" : "warn");
line.textContent = text;
el.appendChild(line);
} else {
el.appendChild(document.createTextNode(text));
}
el.scrollTop = el.scrollHeight;
};
const setBar = (loaded, total) => { $("barFill").style.width = pct(loaded, total).toFixed(1) + "%"; };
const resetBar = () => { $("barFill").style.width = "0%"; };
// Model ingest (download / copy into IndexedDB) has its own bar in the Models
// tab. The top bar tracks engine startup only; one bar for both made a finished
// download look like a starting engine and vice versa.
const showIngest = (label, loaded = 0, total = 0) => {
const box = $("ingest");
box.hidden = false;
box.classList.remove("error");
$("ingestLabel").textContent = label;
$("ingestPercent").textContent = total ? pct(loaded, total).toFixed(1) + "%" : "";
$("ingestFill").style.width = pct(loaded, total).toFixed(1) + "%";
};
const hideIngest = () => {
$("ingest").hidden = true;
$("ingest").classList.remove("error");
$("ingestFill").style.width = "0%";
};
const labelIngest = (label) => { if (!$("ingest").hidden) $("ingestLabel").textContent = label; };
const failIngest = (why) => {
$("ingest").hidden = false;
$("ingest").classList.add("error");
$("ingestLabel").textContent = "error: " + why;
$("ingestPercent").textContent = "";
};
let app = null;
let models = [];
let loadedId = null; // model currently in the wasm instance
let ingestingProjector = null; // name while an mmproj URL is being downloaded
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"];
// the marker mtmd replaces with the image tokens (mtmd_default_marker())
const MM_MARKER = "<__media__>";
let imageTargetRow = null; // row waiting for the image picker
let embedJob = null; // { id, text } while a parent frame drives a run
// Messages to an embedding page. A no-op when this page is not in a frame (or
// when the run was started by the page itself).
const postEmbed = (type, data) => {
if (embedJob) parent.postMessage({ type: `llmjs:${type}`, id: embedJob.id, ...data }, "*");
};
// --- 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-recommended").hidden = name !== "recommended";
$("panel-models").hidden = name !== "models";
if (name === "recommended") renderRecommended();
}
for (const btn of document.querySelectorAll(".tab")) {
btn.onclick = () => showTab(btn.dataset.tab);
}
let threadsUsable = true; // false when the page is not cross-origin isolated
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;
// on a page without COOP/COEP the field stays locked: the single-thread
// module is the only thing that can load there, so editing it would lie
$("threads").disabled = busy || !threadsUsable;
$("useMmproj").disabled = busy;
$("mmprojSelect").disabled = busy || !$("useMmproj").checked ||
!models.some((m) => m.multimodal);
}
// --- parameters -----------------------------------------------------------
const num = (id, fallback) => {
const v = Number($(id).value);
return Number.isFinite(v) ? v : fallback;
};
// engine launch parameters: fixed when llama_context is created, so a change
// takes effect on the next load (or Reload engine), not mid-conversation
function engineParams() {
return {
context_size: Math.max(128, num("ctxSize", DEFAULT_CTX)),
kv: {
type_k: $("kvTypeK").value,
type_v: $("kvTypeV").value,
flash_attn: num("flashAttn", -1),
kv_unified: $("kvUnified").checked,
n_parallel: 1,
},
threads: Math.max(1, Math.min(64, num("threads", 1))),
mmproj_id: $("useMmproj").checked ? ($("mmprojSelect").value || null) : null,
};
}
// 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(),
chat_template_source: templateOverride(),
tools: $("tools").value.trim(),
// the tool path above is untouched: these stay empty unless the picker
// selects them, and a caller grammar wins over the template's own
json_schema: $("constrain").value === "json_schema" ? $("jsonSchema").value.trim() : "",
grammar: $("constrain").value === "grammar" ? $("grammar").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();
const cfg = info.ctx_config || {};
if (cfg.n_ctx) $("ctxSize").value = cfg.n_ctx;
if (cfg.flash_attn !== undefined) $("flashAttn").value = String(cfg.flash_attn);
const mm = info.mmproj;
statusText(`model ready · ${m.name} · n_ctx=${info.n_ctx} · ` +
`KV ${cfg.type_k || "f16"}/${cfg.type_v || "f16"}` +
(cfg.kv_unified ? " unified" : "") +
(cfg.flash_attn === 1 ? " · flash-attn" : "") +
` · ${cfg.n_seq_max || 1} seq · ${info.threads || 1} thread${(info.threads || 1) === 1 ? "" : "s"}` +
(mm && mm.loaded ? ` · mmproj ${info.mmproj_name}${mm.vision ? " (vision)" : ""}` : "") +
` · ` + (loadedTemplate ? "chat template from the GGUF" : "no chat template"));
if (window.parent !== window) {
parent.postMessage({ type: "llmjs:ready", model: m.name,
n_ctx: info.n_ctx, threads: info.threads || 1 }, "*");
}
// the bar belongs to the startup, not to the loaded state
resetBar();
hideIngest();
setState("ready");
refreshModels();
},
// tokens: into the streaming assistant turn, or into the completion box
(text) => {
// a parent frame's run streams to the parent, not into this page's DOM
if (embedJob) { embedJob.text += text; postEmbed("token", { text }); return; }
if (chatRow) {
const ta = chatRow.querySelector(".content");
ta.value += text;
ta.scrollTop = ta.scrollHeight;
} else {
$("out").textContent += text;
}
},
(stats) => {
if (embedJob) {
const job = embedJob;
embedJob = null;
parent.postMessage({ type: "llmjs:done", id: job.id, text: job.text,
stats: { tokens: stats.tokens, ms: stats.ms,
tokens_per_second: stats.tokens_per_second,
prompt_tokens: stats.run_stats && stats.run_stats.prompt_tokens,
stopped: !!stats.interrupted } }, "*");
setState("ready");
return;
}
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.run_stats) {
const r = stats.run_stats;
bits.push(`prompt ${r.prompt_tokens} tok` +
(r.reused_tokens ? ` (${r.reused_tokens} reused from KV)` : "") +
` in ${Math.round(r.prompt_ms)} ms`);
}
if (stats.template_strategy) bits.push(`template: ${stats.template_strategy}`);
if (stats.interrupted) bits.push("stopped");
$("stats").textContent = bits.join(" · ");
setState("ready");
},
(err) => {
if (embedJob) {
const job = embedJob;
embedJob = null;
parent.postMessage({ type: "llmjs:error", id: job.id, error: String(err) }, "*");
setState(loadedId ? "ready" : "idle");
return;
}
if (chatRow) {
chatRow.classList.remove("streaming");
chatRow.querySelector(".content").readOnly = false;
chatRow = null;
}
// an ingest failure belongs to the library surface, not the engine one
if (uiState === "downloading" || uiState === "copying" || ingestingProjector) {
failIngest(String(err));
ingestingProjector = null;
statusText("engine idle — the model library reported an error");
} else {
statusText("error: " + err);
}
log("ERROR: " + err);
setState(loadedId ? "ready" : "idle");
},
(p) => {
const done = p.total > 0 && p.loaded >= p.total;
if (p.phase === "download") {
// library work: the engine bar stays at zero until the bytes are here
setState("downloading");
resetBar();
showIngest(p.complete || done ? "downloaded — starting the engine…"
: `downloading ${fmt(p.loaded)} / ${fmt(p.total)}`, p.loaded, p.total);
} else if (p.phase === "copy") {
setState("copying");
resetBar();
if (done) hideIngest();
else showIngest(`copying into browser storage ${fmt(p.loaded)} / ${fmt(p.total)}`,
p.loaded, p.total);
} else if (p.phase === "hash") {
setState("hashing");
if (ingestingProjector) {
// no engine is starting: the projector is library content
resetBar();
showIngest(`verifying the projector (sha256 ${pct(p.loaded, p.total).toFixed(0)}%)`,
p.loaded, p.total);
statusText("engine idle — verifying the projector");
} else {
// the bytes are in the library; this is startup work
hideIngest();
statusText(`engine starting · verifying model bytes (sha256 ${pct(p.loaded, p.total).toFixed(0)}%)`);
setBar(p.loaded, p.total);
}
} else if (p.phase === "load") {
hideIngest();
setState("loading");
statusText(`engine starting · loading into wasm ${fmt(p.loaded)} / ${fmt(p.total)}`);
setBar(p.loaded, p.total);
}
},
(line, level) => log(line.trimEnd(), level ?? 0),
);
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 = "";
$("urlMmproj").checked = false;
$("fileMmproj").checked = false;
syncIngestButtons();
// adding a projector does not load anything, so nothing reports INITIALIZED
setState(loadedId ? "ready" : "idle");
refreshModels();
break;
case action.MODEL_UPDATED:
if (ingestingProjector) {
const name = ingestingProjector;
ingestingProjector = null;
hideIngest();
resetBar();
statusText(`${name} added to the model library — pick it in the engine row`);
setState(loadedId ? "ready" : "idle");
}
refreshModels();
break;
case action.PAUSED:
labelIngest("paused — press Resume to continue");
statusText("engine idle — the download is paused");
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: DEFAULT_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;
row.images = []; // { name, dataUrl } attached to this message
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"],
["image", "🖼", "attach an image"], ["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);
const thumbs = document.createElement("div");
thumbs.className = "thumbs";
row.append(head, ta, tools, thumbs);
row.addEventListener("dragover", (e) => { e.preventDefault(); row.classList.add("dragover"); });
row.addEventListener("dragleave", () => row.classList.remove("dragover"));
row.addEventListener("drop", (e) => {
e.preventDefault();
row.classList.remove("dragover");
for (const f of e.dataTransfer.files) {
if (f.type.startsWith("image/")) addImageToRow(row, f);
}
});
$("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 === "image") { imageTargetRow = row; $("imageInput").click(); return; }
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);
});
// One media marker per attached image, in order, then the text: that is what
// the template renders and what mtmd replaces with image tokens.
function readMessages({ markers = false } = {}) {
return [...$("messages").querySelectorAll(".msg")]
.map((row) => {
const role = row.querySelector(".role").value;
let content = row.querySelector(".content").value;
if (markers && row.images.length) {
content = row.images.map(() => MM_MARKER).join("") + content;
}
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));
}
// every image in the transcript, in the order the markers were rendered
function readImages() {
return [...$("messages").querySelectorAll(".msg")]
.flatMap((row) => row.images.map((img) => img.dataUrl));
}
function addImageToRow(row, file) {
const reader = new FileReader();
reader.onload = () => {
row.images.push({ name: file.name, dataUrl: String(reader.result) });
renderThumbs(row);
statusText(`${file.name} attached to the ${row.querySelector(".role").value} message`);
};
reader.onerror = () => statusText(`could not read ${file.name}`);
reader.readAsDataURL(file);
}
// mini-avatar thumbnails, each with its own remove button
function renderThumbs(row) {
const box = row.querySelector(".thumbs");
box.innerHTML = "";
row.images.forEach((img, i) => {
const wrap = document.createElement("div");
wrap.className = "thumb";
const el = document.createElement("img");
el.src = img.dataUrl;
el.alt = img.name;
el.title = img.name;
const del = document.createElement("button");
del.className = "danger";
del.textContent = "×";
del.title = "remove this image";
del.onclick = () => { row.images.splice(i, 1); renderThumbs(row); };
wrap.append(el, del);
box.appendChild(wrap);
});
}
$("imageInput").onchange = () => {
const files = [...$("imageInput").files];
const row = imageTargetRow;
imageTargetRow = null;
$("imageInput").value = "";
if (!row) return;
for (const f of files) {
if (f.type.startsWith("image/")) addImageToRow(row, f);
}
};
$("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: readMessages({ markers: true }),
images: readImages(), ...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");
}
const mm = document.createElement("label");
mm.className = "mm";
const mmBox = document.createElement("input");
mmBox.type = "checkbox";
mmBox.checked = !!m.multimodal;
mmBox.title = "this GGUF is a multimodal projector (mmproj), not a text model";
mmBox.onchange = async () => {
await updateModel(m.id, { multimodal: mmBox.checked });
statusText(`${m.name} ${mmBox.checked ? "flagged as" : "no longer"} a multimodal projector`);
refreshModels();
};
mm.append(mmBox, document.createTextNode("mmproj"));
li.append(name, meta, mm, 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));
}
// mmproj files are not text models: they belong to the projector picker
const textModels = models.filter((m) => !m.multimodal);
const select = $("modelSelect");
const wanted = currentId || loadedId || "";
// keep the Recommended cards honest about what is already downloaded
if (recommended && !$("panel-recommended").hidden) renderRecommended();
select.innerHTML = "";
if (!textModels.length) select.appendChild(new Option("no models yet", ""));
for (const m of textModels) {
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 mpSelect = $("mmprojSelect");
const previousMp = mpSelect.value;
mpSelect.innerHTML = "";
const projectors = models.filter((m) => m.multimodal);
if (!projectors.length) mpSelect.appendChild(new Option("no mmproj in the library", ""));
for (const m of projectors) mpSelect.appendChild(new Option(m.name, m.id));
if (projectors.some((m) => m.id === previousMp)) mpSelect.value = previousMp;
mpSelect.disabled = !$("useMmproj").checked || !projectors.length;
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 --------------------------------------------------------------
const needsFile = (m) => m.source === "file" && !m.copied && !attached.has(m.id);
function startLoad(m) {
if (m.multimodal) {
statusText(`${m.name} is an mmproj — tick mmproj in the engine row and pick it there`);
return;
}
// a local file that was never copied needs its File again after a reload
if (needsFile(m)) {
reattachFor = m.id;
statusText(`choose ${m.fileName} again — nothing is re-downloaded, the file stays on your disk`);
$("fileInput").click();
return;
}
const mp = $("useMmproj").checked
? models.find((x) => x.id === $("mmprojSelect").value) || null
: null;
if (mp && needsFile(mp)) {
reattachFor = mp.id;
statusText(`choose ${mp.fileName} (the mmproj) again — nothing is re-downloaded`);
$("fileInput").click();
return;
}
ensureApp();
currentId = m.id;
$("out").textContent = "";
$("stats").textContent = "";
resetBar();
// a URL model that never finished is an ingest job first; its progress is
// reported in the model library, and the engine bar starts at zero
if (m.source === "url" && !m.complete) {
showTab("models");
showIngest("preparing download…");
statusText("engine idle — download progress is in the model library");
} else {
hideIngest();
statusText("starting…");
}
setState("starting");
app.post({
event: action.LOAD,
id: m.id,
file: attached.get(m.id) || null,
mmproj_file: mp ? attached.get(mp.id) || null : null,
...engineParams(),
});
}
$("loadSelected").onclick = () => {
const m = selectedModel();
if (m) startLoad(m);
};
$("reloadEngine").onclick = () => {
if (!app || !currentId) return;
$("out").textContent = "";
$("stats").textContent = "";
hideIngest();
resetBar();
statusText("freeing the model and reloading the engine…");
setState("starting");
const mpId = engineParams().mmproj_id;
app.reload(currentId, {
...engineParams(),
mmproj_file: mpId ? attached.get(mpId) || null : null,
});
};
$("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 = "";
resetBar();
hideIngest();
const copy = $("keepCopy").checked;
const projector = $("fileMmproj").checked;
if (projector) {
statusText(`adding ${file.name} to the model library…`);
if (copy) showIngest("copying the projector into the library…");
} else if (copy) {
showIngest("copying the file into the library…");
statusText("engine idle — copy progress is in the model library");
} else {
statusText("starting…");
}
setState("starting");
app.post({
event: action.ADD_FILE,
file,
name: $("fileLabel").value.trim(),
keepCopy: $("keepCopy").checked,
mmproj: $("fileMmproj").checked,
expectedSha256: $("expectedSha").value.trim().toLowerCase(),
...engineParams(),
});
};
// One place for "download this URL into the library and load it", so the
// Models tab and the Recommended tab cannot drift apart.
function startUrlDownload({ url, name = "", expectedSha256 = "", mmproj = false }) {
ensureApp();
$("out").textContent = "";
$("stats").textContent = "";
resetBar();
hideIngest();
// progress lives in the library, so that is where the download happens
showTab("models");
// a projector is downloaded into the library and nothing is started: it
// becomes a launch parameter of the next model load
if (mmproj) ingestingProjector = name || url.split("/").pop() || "projector";
showIngest("preparing download…");
statusText("engine idle — download progress is in the model library");
setState("starting");
app.post({
event: action.ADD_URL,
url,
name,
mmproj,
expectedSha256: (expectedSha256 || "").trim().toLowerCase(),
...engineParams(),
});
}
$("addUrl").onclick = () => {
const url = $("modelUrl").value.trim();
if (!url) return;
startUrlDownload({
url,
name: $("urlName").value.trim(),
expectedSha256: $("expectedSha").value,
mmproj: $("urlMmproj").checked,
});
};
// --- recommended models (static JSON) -------------------------------------
const RECOMMENDED_JSON = "./recommended-models.json";
let recommended = null; // { models: [...] } once fetched
let recommendedError = "";
async function loadRecommended() {
if (recommended || recommendedError) return;
try {
const res = await fetch(RECOMMENDED_JSON, { cache: "no-cache" });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
recommended = { models: Array.isArray(data.models) ? data.models : [] };
} catch (err) {
recommendedError = String(err);
log(`recommended models: ${err}`);
}
}
// A card is "already yours" if the URL matches, or the bytes do: the sha256
// is what survives a renamed file or a re-download from a mirror.
const libraryMatch = (entry) => models.find((m) =>
(entry.url && m.url && m.url === entry.url) ||
(entry.sha256 && m.sha256 && m.sha256 === entry.sha256));
const chip = (text, cls) => {
const el = document.createElement("span");
el.className = "chip" + (cls ? " " + cls : "");
el.textContent = text;
return el;
};
function renderRecommended() {
const box = $("recList");
if (!recommended && !recommendedError) {
box.innerHTML = '<p class="muted">loading…</p>';
loadRecommended().then(renderRecommended);
return;
}
box.innerHTML = "";
if (recommendedError) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = `could not load ${RECOMMENDED_JSON}: ${recommendedError}`;
box.appendChild(p);
return;
}
if (!recommended.models.length) {
box.innerHTML = '<p class="muted">the list is empty — add entries to recommended.json</p>';
return;
}
for (const entry of recommended.models) {
const card = document.createElement("div");
card.className = "card";
const head = document.createElement("div");
head.className = "card-head";
const title = document.createElement("strong");
title.textContent = entry.name || entry.url;
const link = document.createElement("a");
link.className = "card-link";
link.href = entry.page || entry.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = "Hugging Face ↗";
head.append(title, link);
const chips = document.createElement("div");
chips.className = "chips";
for (const bit of [entry.publisher, entry.params, entry.quant,
entry.size_bytes ? fmt(entry.size_bytes) : ""]) {
if (bit) chips.appendChild(chip(bit));
}
if (entry.multimodal) chips.appendChild(chip("vision", "on"));
for (const tag of entry.tags || []) {
if (entry.multimodal && tag === "vision") continue;
chips.appendChild(chip(tag));
}
const summary = document.createElement("p");
summary.className = "card-summary";
summary.textContent = entry.summary || "";
card.append(head, chips, summary);
if (entry.note) {
const note = document.createElement("p");
note.className = "hint";
note.textContent = entry.note;
card.appendChild(note);
}
const have = libraryMatch(entry);
const go = document.createElement("button");
if (have) {
go.className = "secondary";
go.textContent = "In library — load";
go.title = have.name;
go.onclick = () => startLoad(have);
} else {
go.textContent = "Download";
go.onclick = () => {
if (BUSY.includes(uiState)) return;
startUrlDownload({
url: entry.url,
name: entry.name || "",
expectedSha256: entry.sha256 || "",
});
};
}
const actions = document.createElement("div");
actions.className = "buttons";
actions.appendChild(go);
card.appendChild(actions);
box.appendChild(card);
}
}
$("pause").onclick = () => {
if (app) { app.pause(); labelIngest("pausing…"); statusText("engine idle — pausing the download…"); }
};
$("resume").onclick = () => {
if (!app) return;
labelIngest("resuming…");
statusText("engine idle — resuming the download…");
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();
};
// one constraint at a time: the picker reveals only the box it applies to
function syncConstrain() {
const mode = $("constrain").value;
$("toolsWrap").hidden = mode !== "tools";
$("schemaWrap").hidden = mode !== "json_schema";
$("grammarWrap").hidden = mode !== "grammar";
}
$("constrain").onchange = syncConstrain;
syncConstrain();
$("previewTemplate").onclick = () => {
if (!app) { statusText("load a model first"); return; }
$("templatePreview").textContent = "rendering…";
app.preview_template({
messages: readMessages({ markers: true }),
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);
// the tree is the default view; this is the same arguments as text, for
// copying or for seeing exactly what the model produced
const raw = document.createElement("pre");
raw.className = "tc-raw";
raw.hidden = true;
raw.textContent = JSON.stringify(call.arguments, null, 2);
const toggle = document.createElement("button");
toggle.className = "secondary";
toggle.textContent = "raw JSON";
toggle.title = "switch between the collapsible tree and plain JSON text";
toggle.onclick = () => {
const showRaw = tree.hidden;
tree.hidden = !showRaw;
raw.hidden = showRaw;
toggle.textContent = showRaw ? "raw JSON" : "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" });
};
const actions = document.createElement("div");
actions.className = "buttons";
actions.append(toggle, add);
card.append(title, meta, tree, raw, actions);
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())}`;
}
// Markdown has no images: a placeholder stands in for each one
function contentAsText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const images = content.filter((p) => p.type === "image_url").length;
const text = content.filter((p) => p.type === "text").map((p) => p.text).join("\n");
return [...Array(images).fill("<< image >>"), text].filter(Boolean).join("\n");
}
// export shape: OpenAI messages, multi-part content for messages with images
function exportMessages() {
return [...$("messages").querySelectorAll(".msg")].map((row) => {
const role = row.querySelector(".role").value;
const text = row.querySelector(".content").value;
const imgs = row.images.map((img) => img.dataUrl);
const message = {
role,
content: imgs.length
? [...imgs.map((url) => ({ type: "image_url", image_url: { url } })), { type: "text", text }]
: text,
};
if (role === "tool") {
const id = (row.querySelector(".toolid")?.value || "").trim();
if (id) message.tool_call_id = id;
}
if (row.toolCalls && row.toolCalls.length) message.tool_calls = row.toolCalls;
return message;
}).filter((m) => (typeof m.content === "string"
? m.content.length > 0
: true) || (m.tool_calls && m.tool_calls.length));
}
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`);
const body = contentAsText(m.content);
if (body) out.push(body.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 = exportMessages();
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 = exportMessages();
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 {};
}
}
// One object, two editors: the Chat template tab (for experiments with the
// preview) and the inference rail (per-turn config). They mirror each other,
// so whichever place the user edits, preview and generation both see it.
const kwargBoxes = [$("templateKwargs"), $("runTemplateKwargs")];
const syncKwargs = (src) => {
for (const box of kwargBoxes) if (box !== src) box.value = src.value;
};
for (const box of kwargBoxes) box.addEventListener("input", () => syncKwargs(box));
$("templateKwargs").value = "{}";
syncKwargs($("templateKwargs"));
$("enableThinking").onchange = () => {
const kw = readKwargs();
if ($("enableThinking").checked) kw.enable_thinking = true;
else delete kw.enable_thinking;
$("templateKwargs").value = JSON.stringify(kw, null, 2);
syncKwargs($("templateKwargs"));
};
$("useMmproj").onchange = () => {
$("mmprojSelect").disabled = !$("useMmproj").checked || !models.some((m) => m.multimodal);
statusText($("useMmproj").checked
? "the selected mmproj is loaded with the next model load / engine reload"
: "no multimodal projector will be loaded");
};
// The button says what actually happens: a text model is downloaded and then
// loaded, a projector is only put into the library (nothing to start).
function syncIngestButtons() {
$("addUrl").textContent = $("urlMmproj").checked ? "Download projector" : "Download & load";
$("addFile").textContent = $("fileMmproj").checked ? "Add to library" : "Add & load";
}
$("urlMmproj").onchange = syncIngestButtons;
$("fileMmproj").onchange = syncIngestButtons;
syncIngestButtons();
// The threaded module needs SharedArrayBuffer, i.e. a cross-origin isolated
// page. Without COOP/COEP the single-thread module is what the worker loads,
// and asking for threads would only be a lie.
const threadsAvailable = typeof SharedArrayBuffer === "function" && window.crossOriginIsolated === true;
const cores = navigator.hardwareConcurrency || 1;
// The pthread module pre-spawns its worker pool from a JS expression at
// startup (-sPTHREAD_POOL_SIZE in scripts/build-wasm.sh), so this cap is
// also the useful upper bound: asking for more would wait forever on a
// worker that was never created.
const threadCap = Math.max(1, Math.min(cores, 8));
threadsUsable = threadsAvailable;
// ?threads=N still wins, including ?threads=1 to force single-thread.
const wantedThreads = Number(new URLSearchParams(location.search).get("threads")) || 0;
if (threadsAvailable) {
$("threads").max = String(threadCap);
$("threads").value = String(wantedThreads > 0
? Math.min(threadCap, wantedThreads)
: Math.min(threadCap, 4));
$("threadsHint").textContent =
`pthread wasm active (${cores} cores, pool of ${threadCap}) - changing this rebuilds the context`;
} else {
$("threads").value = "1";
$("threads").disabled = true;
$("threadsHint").textContent =
"single-thread build: this page is not cross-origin isolated (threads need " +
"COOP/COEP headers and SharedArrayBuffer)";
}
// --- embedding API (postMessage) ------------------------------------------
//
// parent -> frame : { type: "llmjs:run", id?, messages | prompt,
// images?, max_tokens?, temperature?, top_p?, top_k?,
// repeat_penalty?, repeat_last_n?, seed?, stop?, tools?,
// template_kwargs? }
// { type: "llmjs:stop" }
// frame -> parent : llmjs:api {version}, llmjs:ready {model,n_ctx,threads},
// llmjs:token {id,text}, llmjs:done {id,text,stats},
// llmjs:error {id,error}
//
// A model still has to be loaded in the frame first (the library lives here);
// this iteration exposes chat completion only, which is the part a host page
// needs to put a local model behind an existing UI.
window.addEventListener("message", (e) => {
if (!e.data || typeof e.data !== "object" || e.source !== window.parent) return;
const msg = e.data;
if (msg.type === "llmjs:stop") {
if (embedJob && app) app.stop();
return;
}
if (msg.type !== "llmjs:run") return;
const reply = (type, extra) =>
parent.postMessage({ type, id: msg.id === undefined ? null : msg.id, ...extra }, "*");
if (!app || !loadedId) {
reply("llmjs:error", { error: "no model is loaded in the frame" });
return;
}
if (embedJob) {
reply("llmjs:error", { error: "a run is already in progress" });
return;
}
const messages = Array.isArray(msg.messages) && msg.messages.length
? msg.messages
: (msg.prompt === undefined ? [] : [{ role: "user", content: String(msg.prompt) }]);
if (!messages.length) {
reply("llmjs:error", { error: "no messages or prompt" });
return;
}
embedJob = { id: msg.id === undefined ? null : msg.id, text: "" };
setState("generating");
app.run({
mode: "chat",
messages,
images: Array.isArray(msg.images) ? msg.images : [],
max_token_len: msg.max_tokens || 96,
temp: msg.temperature === undefined ? 0.7 : msg.temperature,
top_p: msg.top_p === undefined ? 0.9 : msg.top_p,
top_k: msg.top_k === undefined ? 40 : msg.top_k,
repeat_penalty: msg.repeat_penalty === undefined ? 1.1 : msg.repeat_penalty,
repeat_last_n: msg.repeat_last_n === undefined ? 32 : msg.repeat_last_n,
seed: msg.seed === undefined ? -1 : msg.seed,
stop: Array.isArray(msg.stop) ? msg.stop : [],
tools: msg.tools ? JSON.stringify(msg.tools) : "",
template_kwargs: msg.template_kwargs ? JSON.stringify(msg.template_kwargs) : "{}",
});
});
if (window.parent !== window) {
parent.postMessage({ type: "llmjs:api", version: 1 }, "*");
}
setState("idle");
refreshModels();
</script>
</body>
</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; }
/* .workspace/.panel set display:grid/flex, which beats the UA [hidden] rule:
without this the sidebar stayed visible on the template and model tabs and
pushed their panel below it. */
[hidden] { display: none ; }
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; }
/* --- model ingest (Models tab) ---------------------------------------- */
/* separate from the engine bar on purpose: getting bytes into the library
and starting a context are different operations with different outcomes */
#ingest { border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f;
padding: 9px 11px; margin: 0 0 12px; }
#ingest.error { border-color: #5a2530; }
.ingest-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
font-size: 12px; color: #cfd4da; margin-bottom: 7px; }
#ingestPercent { color: #8a8f98; font-size: 11px; }
#ingestBar { height: 8px; background: #0d0f12; border: 1px solid #2a2f37;
border-radius: 999px; overflow: hidden; }
#ingestFill { height: 100%; width: 0%; background: linear-gradient(90deg, #6752c8, #a98bff);
transition: width .15s linear; }
#ingest.error #ingestFill { background: #c05050; }
#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; }
#log .logline { display: block; }
#log .warn { color: #d8b25f; }
#log .err { color: #e07070; }
.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; }
/* a flex item refuses to shrink below its longest option, so a long model name
would push the whole page sideways on a phone; min-width lets it ellipsize */
.appbar select { flex: 1 1 260px; width: auto; min-width: 0; max-width: 100%; }
.appbar button { padding: 7px 12px; font-size: 13px; }
#currentModel { flex: 1 1 100%; font-size: 12px; }
/* --- engine launch row (top bar) -------------------------------------- */
.enginebar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: 8px;
padding: 7px 10px; border: 1px solid #2a2f37; border-radius: 8px; background: #171a1f; }
.enginebar-title { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #8a8f98; }
.ef { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #bfc5cd; white-space: nowrap; }
.ef input[type=number] { width: 66px; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
.ef select { width: auto; background: #0d0f12; color: #e6e6e6; border: 1px solid #2a2f37;
border-radius: 6px; padding: 3px 5px; font: inherit; font-size: 12px; }
.enginebar #mmprojSelect { width: 118px; font-size: 12px; }
.ef.check { gap: 5px; }
.ef input[type=checkbox] { margin: 0; }
#threadsHint { flex: 1 1 100%; margin: 0; font-size: 11px; }
.enginebar details { flex: 1 1 100%; margin: 0; }
.enginebar summary { font-size: 11px; }
.enginebar details p { margin: 6px 0 0; }
.enginebar .hint { margin: 0; }
/* --- 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; }