| <!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; |
| } |
| |
| |
| 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-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; |
| } |
| |
| |
| .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-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; |
| } |
| |
| |
| @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 { |
| 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); |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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 { |
| 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>`; |
| |
| |
| let generator = null; |
| let isGenerating = false; |
| let articleSource = ""; |
| |
| |
| 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"); |
| |
| |
| 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; |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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 → <span>${summaryWordCount}</span> words · ${elapsed}s · <span>${tokPerSec}</span> tok/s`; |
| block.appendChild(stats); |
| |
| isGenerating = false; |
| } |
| |
| |
| async function askQuestion() { |
| const question = chatInput.value.trim(); |
| if (!question || !generator || isGenerating) return; |
| |
| isGenerating = true; |
| chatInput.disabled = true; |
| chatSend.disabled = true; |
| |
| |
| 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 · <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"); |
| }; |
| |
| |
| await loadArticle(); |
| attachSummarizeButtons(); |
| loadModel(); |
| </script> |
| </body> |
| </html> |
|
|