Xenova's picture
Xenova HF Staff
formatting
d839bd6 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LFM2.5 Summarizer | WebGPU</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-hover: #222632;
--border: #2a2e3b;
--text: #e4e4e7;
--text-muted: #8b8fa3;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.25);
--accent-light: #818cf8;
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.7;
}
/* Layout */
main {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 2rem 6rem;
}
.article-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 2.5rem;
align-items: start;
}
.article-aside {
position: sticky;
top: 2.5rem;
}
.article-figure {
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface);
}
.article-figure img {
width: 100%;
display: block;
}
.article-figure figcaption {
padding: 10px 14px;
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-muted);
}
@media (max-width: 768px) {
.article-layout {
grid-template-columns: 1fr;
}
.article-aside {
position: static;
order: -1;
}
.article-figure {
max-width: 320px;
}
}
/* Article typography */
.article-content h1 {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 1.5rem;
line-height: 1.2;
background: linear-gradient(135deg, var(--text), var(--text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.article-content h2 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-top: 2.5rem;
margin-bottom: 1rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.article-content h3 {
font-size: 1.15rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 0.75rem;
color: var(--text-muted);
}
.article-content h4 {
font-size: 1.02rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: var(--text-muted);
}
.article-content p {
margin-bottom: 1.25rem;
font-size: 1.02rem;
position: relative;
}
/* Hoverable paragraphs with summarize button */
.article-content p.hoverable {
padding: 10px 14px;
border-radius: 8px;
transition: background 0.2s;
}
.article-content p.hoverable:hover {
background: var(--surface);
}
.summarize-btn {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
color: var(--accent-light);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: all 0.15s;
white-space: nowrap;
}
.summarize-btn svg {
flex-shrink: 0;
}
.article-content p.hoverable:hover .summarize-btn {
opacity: 1;
pointer-events: auto;
}
.summarize-btn:hover {
background: var(--surface-hover);
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
/* Summary result block */
.summary-block {
background: rgba(99, 102, 241, 0.06);
border-radius: 8px;
padding: 14px 16px;
margin: 1.25rem 0;
}
.summary-block .label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-light);
margin-bottom: 4px;
}
.summary-block .output {
margin: 0;
font-size: 1.02rem;
line-height: 1.7;
}
.summary-block .stats {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.summary-block .stats span {
color: var(--accent-light);
font-weight: 600;
}
/* Spinner */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 1.5px solid var(--accent-glow);
border-top-color: var(--accent-light);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* Chat widget */
.chat-widget {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 150;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
width: 400px;
max-width: calc(100vw - 48px);
}
/* Response bubble */
.chat-response {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
display: none;
animation: bubbleIn 0.2s ease-out;
max-height: 50vh;
overflow-y: auto;
}
@keyframes bubbleIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.chat-response.visible {
display: block;
}
.chat-response-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.chat-response .chat-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-light);
}
.chat-close {
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.chat-close:hover {
background: var(--surface-hover);
color: var(--text);
}
.chat-response .chat-question {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
font-style: italic;
}
.chat-response .chat-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
}
.chat-response .chat-stats {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.chat-response .chat-stats span {
color: var(--accent-light);
font-weight: 600;
}
/* Input row */
.chat-input-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px 6px 6px 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
transition: border-color 0.15s;
}
.chat-input-row:focus-within {
border-color: var(--accent);
}
.chat-input {
flex: 1;
padding: 6px 0;
border: none;
background: transparent;
color: var(--text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.chat-input::placeholder {
color: var(--text-muted);
}
.chat-input:disabled {
opacity: 0.5;
}
.chat-send {
width: 34px;
height: 34px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.chat-send:hover:not(:disabled) {
background: var(--accent-light);
box-shadow: 0 0 16px var(--accent-glow);
}
.chat-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(15, 17, 23, 0.6);
backdrop-filter: blur(4px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.4s;
}
.loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.loading-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 3rem 3rem 2.5rem;
text-align: center;
max-width: 420px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.loading-card h2 {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 6px;
}
.loading-card p {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 28px;
}
.loading-progress {
height: 6px;
background: var(--bg);
border-radius: 3px;
overflow: hidden;
}
.loading-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #a78bfa);
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
.loading-detail {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 14px;
}
</style>
</head>
<body>
<main>
<div class="article-layout">
<div class="article-content" id="article"></div>
<aside class="article-aside">
<figure class="article-figure">
<img src="./assets/artemis.webp" alt="SLS rocket for Artemis II at Launch Complex 39B" />
<figcaption>
The Space Launch System (SLS) rocket for Artemis II at Launch Complex 39B in March 2026
</figcaption>
</figure>
</aside>
</div>
</main>
<div class="chat-widget">
<div class="chat-response" id="chat-response">
<div class="chat-response-header">
<div class="chat-label" id="chat-label"><span class="spinner"></span> Thinking</div>
<button class="chat-close" id="chat-close">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="chat-question" id="chat-question"></div>
<p class="chat-text" id="chat-text"></p>
<div class="chat-stats" id="chat-stats"></div>
</div>
<div class="chat-input-row">
<input
type="text"
class="chat-input"
id="chat-input"
placeholder="Ask about this article..."
autocomplete="off"
disabled
/>
<button class="chat-send" id="chat-send" disabled>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
<div class="loading-overlay" id="loading-overlay">
<div class="loading-card">
<h2>Loading LFM2.5-350M</h2>
<p>Downloading model and compiling WebGPU shaders</p>
<div class="loading-progress">
<div class="loading-progress-fill" id="loading-progress-fill"></div>
</div>
<div class="loading-detail" id="loading-detail">Starting...</div>
</div>
</div>
<script type="module">
import { pipeline, TextStreamer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0";
const SUMMARIZE_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h10M4 18h14"/></svg>`;
// ── State ──
let generator = null;
let isGenerating = false;
let articleSource = "";
// ── DOM refs ──
const article = document.getElementById("article");
const loadingOverlay = document.getElementById("loading-overlay");
const loadingProgressFill = document.getElementById("loading-progress-fill");
const loadingDetail = document.getElementById("loading-detail");
const chatInput = document.getElementById("chat-input");
const chatSend = document.getElementById("chat-send");
const chatResponse = document.getElementById("chat-response");
const chatLabel = document.getElementById("chat-label");
const chatQuestion = document.getElementById("chat-question");
const chatText = document.getElementById("chat-text");
const chatStats = document.getElementById("chat-stats");
const chatClose = document.getElementById("chat-close");
// ── Markdown β†’ HTML ──
function markdownToHTML(md) {
const HEADINGS = [
{ prefix: "#### ", tag: "h4" },
{ prefix: "### ", tag: "h3" },
{ prefix: "## ", tag: "h2" },
{ prefix: "# ", tag: "h1" },
];
const lines = md.split("\n");
let html = "";
let buffer = "";
const flushBuffer = () => {
if (buffer) {
html += `<p>${buffer.trim()}</p>`;
buffer = "";
}
};
for (const line of lines) {
const heading = HEADINGS.find((h) => line.startsWith(h.prefix));
if (heading) {
flushBuffer();
html += `<${heading.tag}>${line.slice(heading.prefix.length)}</${heading.tag}>`;
} else if (line.trim() === "") {
flushBuffer();
} else {
buffer += (buffer ? " " : "") + line.replace(/\[\d+\]/g, "");
}
}
flushBuffer();
return html;
}
// ── Attach summarize buttons to all <p> in the article ──
function attachSummarizeButtons() {
for (const p of article.querySelectorAll("p")) {
if (p.classList.contains("hoverable")) continue;
p.classList.add("hoverable");
const btn = document.createElement("button");
btn.className = "summarize-btn";
btn.innerHTML = `${SUMMARIZE_SVG} Summarize`;
btn.onclick = (e) => {
e.stopPropagation();
summarizeParagraph(p);
};
p.appendChild(btn);
}
}
// ── Load article + model ──
async function loadArticle() {
const res = await fetch("data.txt");
articleSource = await res.text();
article.innerHTML = markdownToHTML(articleSource);
}
async function loadModel() {
loadingDetail.textContent = "Downloading model...";
generator = await pipeline("text-generation", "onnx-community/LFM2.5-350M-ONNX", {
dtype: "q4",
device: "webgpu",
progress_callback: (progress) => {
if (progress.status === "progress_total") {
loadingProgressFill.style.width = `${progress.progress ?? 0}%`;
}
},
});
loadingOverlay.classList.add("hidden");
chatInput.disabled = false;
chatSend.disabled = false;
chatInput.focus();
}
// ── Summarize a paragraph ──
async function summarizeParagraph(p) {
if (!generator || isGenerating) return;
isGenerating = true;
const originalText = p.textContent.trim();
const originalWordCount = originalText.split(/\s+/).length;
const block = document.createElement("div");
block.className = "summary-block";
const label = document.createElement("div");
label.className = "label";
label.innerHTML = `<span class="spinner"></span> Summarizing`;
block.appendChild(label);
const output = document.createElement("p");
output.className = "output";
block.appendChild(output);
p.replaceWith(block);
const t0 = performance.now();
let tFirstToken = null;
let tokenCount = 0;
const streamer = new TextStreamer(generator.tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (text) => {
tFirstToken ??= performance.now();
tokenCount++;
output.textContent += text;
},
});
try {
await generator([{ role: "user", content: `Summarize this:\n\n${originalText}` }], {
max_new_tokens: 512,
do_sample: false,
streamer,
});
} catch (err) {
output.textContent = "Error: " + err.message;
}
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
const summaryWordCount = output.textContent.split(/\s+/).length;
label.textContent = "Summary";
const stats = document.createElement("div");
stats.className = "stats";
stats.innerHTML = `<span>${originalWordCount}</span> words &rarr; <span>${summaryWordCount}</span> words &middot; ${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
block.appendChild(stats);
isGenerating = false;
}
// ── Chat: ask a question about the article ──
async function askQuestion() {
const question = chatInput.value.trim();
if (!question || !generator || isGenerating) return;
isGenerating = true;
chatInput.disabled = true;
chatSend.disabled = true;
// Reset response area
chatQuestion.textContent = question;
chatText.textContent = "";
chatStats.textContent = "";
chatLabel.innerHTML = `<span class="spinner"></span> Thinking`;
chatResponse.classList.add("visible");
chatInput.value = "";
const t0 = performance.now();
let tFirstToken = null;
let tokenCount = 0;
const streamer = new TextStreamer(generator.tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function: (text) => {
tFirstToken ??= performance.now();
tokenCount++;
chatText.textContent += text;
},
});
try {
await generator(
[
{
role: "system",
content: `You are a helpful assistant. Answer the user's question based on the following article. Be concise and accurate. If the answer is not in the article, say so.\n\n${articleSource}`,
},
{ role: "user", content: question },
],
{ max_new_tokens: 512, do_sample: false, streamer },
);
} catch (err) {
chatText.textContent = "Error: " + err.message;
}
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
chatLabel.textContent = "Answer";
chatStats.innerHTML = `${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
isGenerating = false;
chatInput.disabled = false;
chatSend.disabled = false;
chatInput.focus();
}
chatSend.onclick = askQuestion;
chatInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
askQuestion();
}
});
chatClose.onclick = () => {
if (!isGenerating) chatResponse.classList.remove("visible");
};
// ── Init ──
await loadArticle();
attachSummarizeButtons();
loadModel();
</script>
</body>
</html>