Download src/popup/popup.js from nyaaorick/everything-webgpu: direct link, hf CLI and curl.
- Browser
- Download file 8.47 kB
-
https://huggingface.co/nyaaorick/everything-webgpu/resolve/main/src/popup/popup.js
- Command line
-
hf download hf://nyaaorick/everything-webgpu/src/popup/popup.js
-
curl -L -o popup.js https://huggingface.co/nyaaorick/everything-webgpu/resolve/main/src/popup/popup.js
8.47 kB
| /** Minimal test chat. Thin client over the background engine host's port protocol. */ | |
| import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js"; | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| dot: $("dot"), model: $("model"), load: $("load"), unload: $("unload"), | |
| progress: $("progress"), status: $("status"), loadHint: $("loadHint"), log: $("log"), input: $("input"), | |
| send: $("send"), stop: $("stop"), clear: $("clear"), manage: $("manage"), | |
| }; | |
| const history = []; | |
| let models = []; | |
| let engineState = { status: ENGINE_STATE.IDLE, modelId: null, pool: null }; | |
| let streamId = null; | |
| let streamEl = null; | |
| /** | |
| * The engine supersedes by `session`, so a cleared conversation has to become a | |
| * *different* session — otherwise the next message looks to the scheduler like | |
| * a continuation of the one just thrown away. | |
| */ | |
| const newSession = () => `popup-${crypto.randomUUID()}`; | |
| let session = newSession(); | |
| const port = browser.runtime.connect({ name: PORT_NAME }); | |
| port.onMessage.addListener(onPortMessage); | |
| // -------------------------------------------------------------- rendering --- | |
| /** | |
| * The second line of the load guide. | |
| * | |
| * Deliberately not WebLLM's stock wording. Its default hint is about populating | |
| * a cache from the network on first visit, which is never what happens here: | |
| * the weights were injected into Cache Storage by drag-and-drop, so nothing is | |
| * downloaded and a "later refreshes are faster" promise would be wrong. What | |
| * the user is actually waiting on, once the shards are read, is WebGPU shader | |
| * compilation. | |
| */ | |
| function renderLoadHint(progress) { | |
| if (!progress) return void (els.loadHint.hidden = true); | |
| const secs = progress.timeElapsed; | |
| const parts = []; | |
| if (progress.engines > 1) parts.push(`${progress.engines} engines, loaded 2 at a time`); | |
| parts.push("weights come from the local cache — nothing is downloaded"); | |
| if ((progress.progress ?? 0) > 0.99) { | |
| parts.push("compiling WebGPU shaders, which is most of the wait"); | |
| } | |
| els.loadHint.textContent = `${secs ? `${secs}s elapsed · ` : ""}${parts.join(" · ")}.`; | |
| els.loadHint.hidden = false; | |
| } | |
| function renderState() { | |
| const { status, modelId, progress, error, pool } = engineState; | |
| els.dot.className = `dot ${status}`; | |
| els.progress.style.width = `${Math.round((progress?.progress ?? (status === ENGINE_STATE.READY ? 1 : 0)) * 100)}%`; | |
| els.status.textContent = | |
| error ? error : | |
| progress?.text ? progress.text : | |
| status === ENGINE_STATE.READY ? `Ready — ${modelId}` : | |
| status === ENGINE_STATE.LOADING ? `Loading ${modelId}…` : | |
| "Idle — pick a model and press Load"; | |
| if (!error && status === ENGINE_STATE.READY && pool && (pool.busy || pool.queued)) { | |
| const cap = pool.size < pool.maxSize && !pool.growthBlocked ? `/${pool.maxSize}` : ""; | |
| els.status.textContent += ` · ${pool.busy}/${pool.size}${cap} busy, ${pool.queued} queued`; | |
| } | |
| els.status.classList.toggle("banner", Boolean(error)); | |
| els.status.classList.toggle("error", Boolean(error)); | |
| const loading = status === ENGINE_STATE.LOADING; | |
| // The report is a full sentence from WebLLM ("Loading model from cache[26/58]: | |
| // 890MB loaded. 51% completed, 121 secs elapsed."). #status is a single | |
| // ellipsised line by default, which cut it off, so unclip it while loading. | |
| els.status.classList.toggle("loading", loading && Boolean(progress?.text)); | |
| renderLoadHint(loading ? progress : null); | |
| els.load.disabled = loading || !els.model.value; | |
| els.unload.disabled = loading || status !== ENGINE_STATE.READY; | |
| els.model.disabled = loading; | |
| els.send.disabled = loading || streamId !== null; | |
| els.stop.hidden = streamId === null; | |
| els.load.textContent = status === ENGINE_STATE.READY && modelId === els.model.value ? "Reload" : "Load"; | |
| } | |
| function renderEmpty() { | |
| els.log.replaceChildren( | |
| Object.assign(document.createElement("div"), { | |
| className: "empty", | |
| textContent: models.length | |
| ? "Load a model, then send a message." | |
| : "No local models yet. Open Models… and drop a compiled MLC model folder.", | |
| }), | |
| ); | |
| } | |
| function addMessage(role, text) { | |
| if (els.log.querySelector(".empty")) els.log.replaceChildren(); | |
| const el = document.createElement("div"); | |
| el.className = `msg ${role}`; | |
| el.textContent = text; | |
| els.log.append(el); | |
| els.log.scrollTop = els.log.scrollHeight; | |
| return el; | |
| } | |
| // ---------------------------------------------------------------- actions --- | |
| async function ask(op, payload) { | |
| const res = await browser.runtime.sendMessage(request(op, payload)); | |
| if (!res?.ok) throw new Error(res?.error ?? "Engine did not respond."); | |
| return res; | |
| } | |
| async function refreshModels() { | |
| const res = await ask(OP.LIST_MODELS); | |
| models = res.models; | |
| engineState = res.state; | |
| els.model.replaceChildren( | |
| ...models.map((m) => new Option(`${m.model_id} · ${(m.sizeBytes / 1e9).toFixed(1)} GB`, m.model_id)), | |
| ); | |
| if (res.state.modelId) els.model.value = res.state.modelId; | |
| if (!els.log.children.length || els.log.querySelector(".empty")) renderEmpty(); | |
| renderState(); | |
| } | |
| function send() { | |
| const text = els.input.value.trim(); | |
| if (!text) return; | |
| els.input.value = ""; | |
| history.push({ role: "user", content: text }); | |
| addMessage("user", text); | |
| streamId = crypto.randomUUID(); | |
| streamEl = addMessage("assistant", ""); | |
| renderState(); | |
| port.postMessage({ | |
| protocol: PROTOCOL, | |
| op: PORT_OP.CHAT_STREAM, | |
| id: streamId, | |
| modelId: els.model.value || undefined, | |
| messages: history, | |
| priority: PRIORITY.INTERACTIVE, | |
| session, | |
| }); | |
| } | |
| function onPortMessage(msg) { | |
| if (msg?.protocol !== PROTOCOL) return; | |
| switch (msg.op) { | |
| case PORT_OP.ENGINE_STATE: | |
| engineState = msg.state; | |
| if (msg.state.modelId && !els.model.value) els.model.value = msg.state.modelId; | |
| renderState(); | |
| return; | |
| case PORT_OP.CHUNK: | |
| if (msg.id !== streamId) return; | |
| streamEl.textContent += msg.delta; | |
| els.log.scrollTop = els.log.scrollHeight; | |
| return; | |
| case PORT_OP.DONE: { | |
| if (msg.id !== streamId) return; | |
| const text = streamEl.textContent; | |
| history.push({ role: "assistant", content: text }); | |
| if (msg.usage) { | |
| const meta = document.createElement("span"); | |
| meta.className = "meta"; | |
| meta.textContent = `${msg.usage.completion_tokens} tok · ${(msg.usage.extra?.decode_tokens_per_s ?? 0).toFixed(1)} tok/s`; | |
| streamEl.append(meta); | |
| } | |
| streamId = null; | |
| streamEl = null; | |
| renderState(); | |
| return; | |
| } | |
| case PORT_OP.ERROR: | |
| if (streamEl && !streamEl.textContent) streamEl.remove(); | |
| addMessage("error", msg.error); | |
| streamId = null; | |
| streamEl = null; | |
| renderState(); | |
| return; | |
| } | |
| } | |
| // ------------------------------------------------------------------ wiring --- | |
| els.load.addEventListener("click", () => | |
| ask(OP.LOAD, { modelId: els.model.value }).catch((err) => { | |
| engineState = { ...engineState, status: ENGINE_STATE.ERROR, error: err.message }; | |
| renderState(); | |
| }), | |
| ); | |
| els.unload.addEventListener("click", () => ask(OP.UNLOAD).catch(() => {})); | |
| els.send.addEventListener("click", send); | |
| els.stop.addEventListener("click", () => | |
| port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session }), | |
| ); | |
| /** | |
| * Discard the conversation and start a fresh session. | |
| * | |
| * All three parts matter. Dropping `history` alone left every rendered message | |
| * on screen, which read as the button doing nothing; and leaving the old | |
| * session key in place meant a request that was still streaming kept writing | |
| * into a conversation the user had already thrown away. | |
| */ | |
| els.clear.addEventListener("click", () => { | |
| if (streamId) { | |
| port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session }); | |
| streamId = null; | |
| streamEl = null; | |
| } | |
| history.length = 0; | |
| session = newSession(); | |
| renderEmpty(); | |
| els.input.value = ""; | |
| renderState(); | |
| }); | |
| els.manage.addEventListener("click", () => { | |
| browser.runtime.openOptionsPage(); | |
| window.close(); | |
| }); | |
| els.model.addEventListener("change", renderState); | |
| els.input.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (!els.send.disabled) send(); | |
| } | |
| }); | |
| refreshModels().catch((err) => { | |
| els.status.textContent = err.message; | |
| els.status.classList.add("banner", "error"); | |
| }); | |