/** Manager page: model ingestion, registry maintenance, engine settings, setup help. */ import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js"; import { webExtensionStorage } from "../adapters/webext.js"; import { ModelStore, formatBytes } from "../engine/model-store.js"; import { filesFromDataTransfer, filesFromInput, ingestModelFolder } from "../engine/ingest.js"; import { clampSteps } from "../engine/multistep.js"; /** * The manager page writes into the same Cache Storage and the same * `browser.storage.local` the background page reads, so it builds its own * ModelStore over the same adapter rather than routing registry edits through * the engine. Only generation goes over the wire. */ const store = new ModelStore(webExtensionStorage()); const $ = (id) => document.getElementById(id); const port = browser.runtime.connect({ name: PORT_NAME }); port.onMessage.addListener((msg) => { if (msg?.protocol === PROTOCOL && msg.op === PORT_OP.ENGINE_STATE) renderEngine(msg.state); }); let lastEngineState = {}; function renderEngine(state) { lastEngineState = state; $("dot").className = `dot ${state.status}`; // `size` is engines that exist, which trails `maxSize` until a second task // asks for one — so say both, or a pool of 1 under a cap of 2 reads as a bug. const pool = state.pool?.size ? ` · pool ${state.pool.busy}/${state.pool.size} busy, ${state.pool.queued} queued` + (state.pool.growthBlocked ? ` · stayed at ${state.pool.size} (${state.pool.growthBlocked})` : state.pool.size < state.pool.maxSize ? ` · up to ${state.pool.maxSize} on demand` : "") : ""; const loading = state.status === ENGINE_STATE.LOADING; // Show WebLLM's own report verbatim while loading — shard counts, MB and // elapsed seconds are the only feedback there is during a ~48 s load, and a // bare percentage hides all of it. $("engineStatus").textContent = state.error ? state.error : state.status === ENGINE_STATE.READY ? `loaded: ${state.modelId}${pool}` : loading ? state.progress?.text ?? `loading ${state.modelId}…` : "idle"; $("engineBar").hidden = !loading; $("engineProgress").style.width = `${Math.round((state.progress?.progress ?? 0) * 100)}%`; const hint = $("engineHint"); if (!loading) { hint.hidden = true; } else { const secs = state.progress?.timeElapsed; // Not WebLLM's stock "first visit populates the cache" line: these weights // were injected by drag-and-drop, so nothing is ever downloaded. hint.textContent = `${secs ? `${secs}s elapsed · ` : ""}reading from local cache, no network` + ((state.progress?.progress ?? 0) > 0.99 ? " · compiling WebGPU shaders" : "") + "."; hint.hidden = false; } } // ------------------------------------------------------------ diagnostics --- function renderGpu() { const el = $("gpu"); if (navigator.gpu) { el.hidden = false; el.textContent = "WebGPU is available in this context."; return; } el.hidden = false; el.classList.add("error"); el.textContent = "navigator.gpu is missing. Set dom.webgpu.enabled = true in about:config (see Firefox setup below) and restart Firefox — models cannot load until then."; } /** Spells out what another engine actually costs, from the loaded model's own record. */ async function renderPoolCost() { const { engineCount } = await store.getSettings(); const record = (await store.list()).find((m) => m.model_id === lastEngineState.modelId) ?? (await store.list())[0]; if (!record) return void ($("poolCost").textContent = ""); const weights = record.sizeBytes ?? 0; const total = weights * engineCount; // Measured on an M4 Air with a 0.8B model: 2 engines gave 1.6x aggregate // throughput, 4 gave 0.3x - past the memory budget they starve each other. const verdict = engineCount === 1 ? "no parallelism: batches run one at a time." : engineCount === 2 ? "measured ~1.6x aggregate throughput on a 0.8B model." : "more is usually worse — 4 engines measured 3x SLOWER than 1. Verify with npm run e2e before keeping this."; $("poolCost").textContent = `${engineCount} engine(s) x ~${formatBytes(weights)} = ~${formatBytes(total)} VRAM. ${verdict}`; $("poolCost").classList.toggle("warn", total > 6e9 || engineCount > 2); } /** * Spells out the sawtooth, because "more steps" is not monotonically better. * * Firefox resolves a GPU sync only on a 100 ms tick, so a burst of K steps costs * a whole number of ticks. The reference figure is the ~7.3 ms/token of real * compute measured for a 0.8B (AI.md, "The 10 tok/s ceiling"); a bigger model * costs more per step and wants a smaller K. */ const TICK_MS = 100; const REFERENCE_STEP_MS = 7.3; async function renderDecodeCost() { const { decodeSteps } = await store.getSettings(); const ticks = Math.ceil((decodeSteps * REFERENCE_STEP_MS) / TICK_MS); const rate = decodeSteps / ((ticks * TICK_MS) / 1000); const perTick = Math.floor(TICK_MS / REFERENCE_STEP_MS); const wastes = decodeSteps > perTick && decodeSteps % perTick !== 0; $("decodeCost").textContent = `${decodeSteps} step(s) per sync = ${decodeSteps} token(s) every ${ticks} tick(s) ` + `≈ ${rate.toFixed(0)} tok/s on a 0.8B (vs 9.6 at 1 step). ` + (wastes ? `${decodeSteps} spills past a 100 ms tick boundary — ${perTick} fits inside one tick and measures faster. Confirm with npm run e2e.` : "Fits the tick grid. Re-check on a larger model: per-step compute grows, so the best K shrinks."); $("decodeCost").classList.toggle("warn", wastes); } async function renderQuota() { if (!navigator.storage?.estimate) return; const { usage, quota } = await navigator.storage.estimate(); $("quota").textContent = `${formatBytes(usage)} of ${formatBytes(quota)}`; } // --------------------------------------------------------------- ingestion --- const drop = $("drop"); for (const type of ["dragenter", "dragover"]) { drop.addEventListener(type, (e) => { e.preventDefault(); drop.classList.add("over"); }); } for (const type of ["dragleave", "drop"]) { drop.addEventListener(type, () => drop.classList.remove("over")); } drop.addEventListener("drop", async (e) => { e.preventDefault(); await ingest(await filesFromDataTransfer(e.dataTransfer)); }); $("pick").addEventListener("click", () => $("picker").click()); $("picker").addEventListener("change", async (e) => { await ingest(filesFromInput(e.target.files)); e.target.value = ""; }); async function ingest(entries) { $("ingestError").hidden = true; $("ingest").hidden = false; $("ingestBar").style.width = "0%"; $("ingestStatus").textContent = "Validating…"; try { const record = await ingestModelFolder(entries, { store, onProgress: ({ phase, done, total, label }) => { $("ingestBar").style.width = `${Math.round((done / Math.max(total, 1)) * 100)}%`; $("ingestStatus").textContent = phase === "validating" ? label : `Caching ${done}/${total} — ${label}`; }, }); $("ingestStatus").textContent = `Registered ${record.model_id} (${formatBytes(record.sizeBytes)}, ${record.shardCount} shards).`; await Promise.all([renderModels(), renderQuota()]); } catch (err) { $("ingest").hidden = true; $("ingestError").hidden = false; $("ingestError").textContent = err.message; } } // ---------------------------------------------------------------- registry --- async function renderModels() { const models = await store.list(); const tbody = $("models").querySelector("tbody"); tbody.replaceChildren(); $("models").hidden = models.length === 0; $("noModels").hidden = models.length > 0; for (const record of models) { const tr = document.createElement("tr"); tr.innerHTML = `