blog / summarize.js
anon's picture
Add FPS-adaptive "ultra performance" + gate model load on WebGPU adapter
a757215
Raw
History Blame Contribute Delete
14.6 kB
// AI summaries per article section, running fully on-device via WebGPU.
// Model runtime vendored from huggingface.co/spaces/webml-community/lfm2-webgpu-kernels
// (Lfm2Mobile: GGUF loader + custom WGSL kernels + generation loop, all in lfm2_5.js).
import { Lfm2Mobile, DEFAULT_MODEL_ID } from "./lfm2_5.js";
// A "section" is an H1 or H2 plus every direct-child element after it up to the next
// H1/H2 (mirrors the boundaries the .article-sidebar TOC already uses — H3s are
// sub-headings within their parent section, not their own summarizable unit).
const HEADING_SELECTOR = ":scope > h1, :scope > h2";
const MIN_CHARS = 60; // skip empty/near-empty sections (e.g. a heading right before the next one)
const MAX_NEW_TOKENS = 512;
const CIRC = 2 * Math.PI * 13; // matches the r=13 <circle> in renderRing()
// Model + its download/warmup are shared across every section on the page: only the
// first click anywhere pays the load cost. `progressListeners` fans load progress out to
// every ring currently waiting on it (a section clicked mid-download gets live updates,
// not just the one that triggered the load). `genTail` serializes generate() calls, since
// the model can only run one generation at a time.
let model = null;
let loadPromise = null;
const progressListeners = new Set();
let genTail = Promise.resolve();
// Confirm WebGPU is actually usable BEFORE we ever touch the model. `navigator.gpu`
// merely existing isn't enough — a browser can expose the API yet hand back no
// adapter (blocklisted GPU, headless, disabled flag), so we request an adapter and
// only resolve once one comes back. The result is cached so repeated summary clicks
// don't re-probe; a failure clears the cache so a later attempt can retry.
let webgpuReady = null;
function ensureWebGPU() {
if (!navigator.gpu) {
return Promise.reject(new Error("Summaries need a WebGPU browser (recent Chrome or Edge)."));
}
if (!webgpuReady) {
webgpuReady = (async () => {
let adapter = null;
try {
adapter = await navigator.gpu.requestAdapter();
} catch {
adapter = null;
}
if (!adapter) {
throw new Error("WebGPU is present but no compatible GPU adapter is available on this device.");
}
return adapter;
})().catch((err) => {
webgpuReady = null; // let a future click re-probe
throw err;
});
}
return webgpuReady;
}
document.addEventListener("DOMContentLoaded", init);
function init() {
const article = document.querySelector(".article");
if (!article) return;
// Read the section boundaries from the ORIGINAL tree before any wrapping happens below
// (wrapping a heading only reparents the heading itself, not its following siblings, but
// computing every section's content list up front keeps this a clean read-then-write pass).
// Also: must run before script.js wraps <pre> blocks into .code-collapsible — relies on
// this file's <script type="module"> tag appearing before ../script.js in each post's
// HTML, which makes it execute first (defer/module scripts run in document order).
const headings = Array.from(article.querySelectorAll(HEADING_SELECTOR));
headings
.map((heading, i) => ({ heading, contentEls: collectSection(heading, headings[i + 1] || null) }))
.filter(({ contentEls }) => sectionText(contentEls).length >= MIN_CHARS)
.forEach(({ heading, contentEls }) => setupSection(heading, contentEls));
}
function collectSection(heading, until) {
const els = [];
let node = heading.nextElementSibling;
while (node && node !== until) {
els.push(node);
node = node.nextElementSibling;
}
return els;
}
function sectionText(contentEls) {
return contentEls.map(extractElementText).filter(Boolean).join("\n\n");
}
// textContent of a stripped clone so we never touch the live DOM; drops KaTeX's hidden
// MathML annotation clone (raw LaTeX source) so math-heavy sections don't feed the model
// duplicated/garbled text. Tables and lists get a lightweight text layout so cells/items
// don't run together; code blocks keep their original whitespace.
function extractElementText(el) {
const clone = el.cloneNode(true);
clone.querySelectorAll(".katex-mathml").forEach((n) => n.remove());
if (clone.tagName === "TABLE") return extractTableText(clone);
if (clone.tagName === "UL" || clone.tagName === "OL") return extractListText(clone);
if (clone.tagName === "PRE") return clone.textContent.trim();
return clone.textContent.replace(/\s+/g, " ").trim();
}
function extractTableText(table) {
return Array.from(table.querySelectorAll("tr"))
.map((tr) =>
Array.from(tr.querySelectorAll("th,td"))
.map((cell) => cell.textContent.replace(/\s+/g, " ").trim())
.join(" | ")
)
.join("\n");
}
function extractListText(list) {
return Array.from(list.querySelectorAll(":scope > li"))
.map((li) => "- " + li.textContent.replace(/\s+/g, " ").trim())
.join("\n");
}
function setupSection(heading, contentEls) {
const wrap = document.createElement("div");
wrap.className = "ai-sum-wrap";
heading.parentNode.insertBefore(wrap, heading);
wrap.appendChild(heading);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ai-sum-btn tooltip";
btn.setAttribute("aria-label", "Summarize this section");
btn.dataset.tooltip = "Summarize this section";
btn.innerHTML = SUMMARIZE_ICON;
heading.appendChild(btn);
let note = null;
let state = "idle"; // idle | busy | done | error
btn.addEventListener("click", () => {
if (state === "busy") return;
if (state === "done") {
toggleNote();
return;
}
startSummary(); // idle or error -> (re)start
});
function ensureNote() {
if (note) return note;
note = document.createElement("div");
note.className = "ai-sum-note";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ai-sum-note-toggle";
toggle.setAttribute("aria-expanded", "true");
toggle.innerHTML = '<span class="ai-sum-arrow">▼</span><span>AI summary</span>';
toggle.addEventListener("click", toggleNote);
const body = document.createElement("div");
body.className = "ai-sum-note-body";
body.setAttribute("aria-live", "polite");
note.appendChild(toggle);
note.appendChild(body);
wrap.appendChild(note); // right after the heading, before the section's own content
return note;
}
function toggleNote() {
if (!note) return;
const collapsed = note.classList.toggle("collapsed");
note.querySelector(".ai-sum-note-toggle")
.setAttribute("aria-expanded", String(!collapsed));
btn.dataset.tooltip = collapsed
? "Show summary"
: "Hide summary";
}
async function startSummary() {
state = "busy";
btn.classList.add("is-busy");
btn.disabled = true;
ensureNote();
note.classList.remove("collapsed");
note.querySelector(".ai-sum-note-toggle").setAttribute("aria-expanded", "true");
const body = note.querySelector(".ai-sum-note-body");
const ring = renderRing(body, "Requesting GPU device…");
const onProgress = (event) => applyLoadProgress(ring, event);
try {
// Confirm a real GPU adapter exists first; only then do we start pulling
// the (large) model down. Never the other way around.
await ensureWebGPU();
if (!model) {
progressListeners.add(onProgress);
try {
model = await getModel();
} finally {
progressListeners.delete(onProgress);
}
}
ring.setLabel("Generating summary…");
ring.setDetail("");
ring.setIndeterminate();
function limitWords(text, maxWords) {
return text.split(/\s+/).slice(0, maxWords).join(" ");
}
const input = limitWords(sectionText(contentEls), 130);
const prompt = "Summarize this section directly:\n\n" + input;
let textEl = null; // created on the first streamed chunk, once we drop the ring
const summary = await enqueueGenerate(() =>
runGenerate(prompt, (partial) => {
if (!textEl) {
ring.destroy();
textEl = renderStreamText(body);
}
textEl.textContent = partial;
})
);
const finalEl = textEl || renderStreamText(body); // stream yielded nothing before completing
finalEl.textContent = summary || "(empty response)";
finalEl.classList.remove("ai-sum-streaming");
state = "done";
} catch (err) {
console.error(err);
ring.destroy();
body.innerHTML = "";
const errEl = document.createElement("p");
errEl.className = "ai-sum-error";
errEl.textContent = "Couldn't generate a summary — " + (err?.message ?? String(err));
body.appendChild(errEl);
state = "error";
} finally {
btn.classList.remove("is-busy");
btn.disabled = false;
}
}
}
function getModel() {
if (!loadPromise) {
loadPromise = (async () => {
const m = await Lfm2Mobile.load(DEFAULT_MODEL_ID, {
onProgress: (event) => {
for (const fn of progressListeners) fn(event);
},
});
for (const fn of progressListeners) fn({ status: "warmup" });
await m.warmup();
return m;
})().catch((err) => {
loadPromise = null; // let the next click retry the load
throw err;
});
}
return loadPromise;
}
// Runs one generation exclusively — chained onto the shared tail so two sections clicked
// close together never call generate() on the model concurrently.
function enqueueGenerate(fn) {
const run = genTail.then(fn, fn);
genTail = run.then(
() => {},
() => {}
);
return run;
}
// Streams the reply, but caps how often onChunk actually touches the DOM — small models
// can emit far faster than the screen (or a screen reader on the note's aria-live region)
// can usefully keep up with.
const STREAM_RENDER_MS = 90;
async function runGenerate(prompt, onChunk) {
const stream = model.generate([{ role: "user", content: prompt }], {
maxNewTokens: MAX_NEW_TOKENS,
});
let full = "";
let lastRenderAt = 0;
for await (const chunk of stream) {
full = chunk.text;
const now = performance.now();
if (now - lastRenderAt >= STREAM_RENDER_MS) {
lastRenderAt = now;
onChunk?.(full);
}
}
return full.trim();
}
function renderStreamText(container) {
container.innerHTML = "";
const el = document.createElement("span");
el.className = "ai-sum-streaming";
container.appendChild(el);
return el;
}
// Maps Lfm2Mobile.load()'s onProgress events onto a 0..0.9 ring (0.9..1 is warmup,
// driven separately below), mirroring the cost-weighted mapping the kernels demo uses:
// the GGUF download dwarfs everything else, so it owns most of the bar.
function applyLoadProgress(ring, event) {
if (event.status === "warmup") {
ring.setLabel("Warming up kernels…");
ring.setDetail("");
ring.setIndeterminate();
return;
}
const frac = Number.isFinite(event.fraction) ? Math.max(0, Math.min(1, event.fraction)) : null;
const labels = {
init: "Requesting GPU device…",
tokenizer: "Loading tokenizer…",
weights: event.fromCache ? "Loading cached model…" : "Downloading model…",
ready: "Preparing GPU weights…",
};
ring.setLabel(labels[event.status] ?? String(event.status));
if (event.status === "weights") {
if (frac !== null) ring.setProgress(0.08 + frac * 0.82);
if (Number.isFinite(event.loaded) && Number.isFinite(event.total)) {
ring.setDetail(formatBytes(event.loaded) + " / " + formatBytes(event.total));
} else {
ring.setDetail("");
}
} else if (event.status === "tokenizer") {
ring.setProgress(0.06);
ring.setDetail("");
} else if (event.status === "ready") {
ring.setProgress(0.9);
ring.setDetail("");
} else {
ring.setProgress(0.02);
ring.setDetail("");
}
}
function formatBytes(n) {
if (n < 1024) return n + " B";
const units = ["KB", "MB", "GB"];
let u = -1;
do {
n /= 1024;
u++;
} while (n >= 1024 && u < units.length - 1);
return n.toFixed(1) + " " + units[u];
}
// A small circular (never spinning) progress indicator: a determinate ring driven by
// setProgress() while we know a real fraction (model download), and an indeterminate
// "breathing" ring — stroke-dashoffset oscillating via CSS keyframes — once we don't
// (warmup, token generation).
function renderRing(container, initialLabel) {
container.innerHTML = "";
const wrapEl = document.createElement("div");
wrapEl.className = "ai-sum-progress";
wrapEl.innerHTML =
'<span class="ai-sum-ring">' +
'<svg viewBox="0 0 32 32">' +
'<circle class="ai-sum-ring-track" cx="16" cy="16" r="13"/>' +
'<circle class="ai-sum-ring-fill" cx="16" cy="16" r="13" ' +
'stroke-dasharray="' +
CIRC.toFixed(2) +
'" stroke-dashoffset="' +
CIRC.toFixed(2) +
'"/>' +
"</svg>" +
"</span>" +
'<span class="ai-sum-progress-text">' +
'<span class="ai-sum-progress-label"></span>' +
'<span class="ai-sum-progress-detail"></span>' +
"</span>";
container.appendChild(wrapEl);
const fill = wrapEl.querySelector(".ai-sum-ring-fill");
const ringEl = wrapEl.querySelector(".ai-sum-ring");
const labelEl = wrapEl.querySelector(".ai-sum-progress-label");
const detailEl = wrapEl.querySelector(".ai-sum-progress-detail");
const api = {
setLabel(text) {
labelEl.textContent = text;
},
setDetail(text) {
detailEl.textContent = text || "";
},
setProgress(frac) {
ringEl.classList.remove("is-indeterminate");
fill.style.strokeDashoffset = (CIRC * (1 - Math.max(0, Math.min(1, frac)))).toFixed(2);
},
setIndeterminate() {
ringEl.classList.add("is-indeterminate");
},
destroy() {
wrapEl.remove();
},
};
api.setLabel(initialLabel);
api.setProgress(0.02);
return api;
}
// A plain "condensed text" glyph — three lines collapsing into a short one —
// that reads as "summarize" without borrowing the four-point spark silhouette
// people now associate with Gemini.
const SUMMARIZE_ICON =
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" ' +
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<line x1="4" y1="6" x2="20" y2="6"/>' +
'<line x1="4" y1="11" x2="20" y2="11"/>' +
'<line x1="4" y1="16" x2="14" y2="16"/>' +
"</svg>";