Spaces:
Running
Running
Download index.html from lemonteaa/llama-cpp-wasm: direct link, hf CLI and curl.
- Browser
- Download file 44.4 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
44.4 kB
| <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 (\n and \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 & 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 & 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> | |