Xenova HF Staff commited on
Commit
d839bd6
Β·
verified Β·
1 Parent(s): d5d8c41

formatting

Browse files
Files changed (1) hide show
  1. index.html +756 -730
index.html CHANGED
@@ -1,762 +1,788 @@
1
- <!DOCTYPE html>
2
  <html lang="en">
3
-
4
- <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>LFM2.5 Summarizer | WebGPU</title>
8
  <style>
9
- :root {
10
- --bg: #0f1117;
11
- --surface: #1a1d27;
12
- --surface-hover: #222632;
13
- --border: #2a2e3b;
14
- --text: #e4e4e7;
15
- --text-muted: #8b8fa3;
16
- --accent: #6366f1;
17
- --accent-glow: rgba(99, 102, 241, 0.25);
18
- --accent-light: #818cf8;
19
- --radius: 12px;
20
- }
21
-
22
- * {
23
- margin: 0;
24
- padding: 0;
25
- box-sizing: border-box;
26
- }
27
-
28
- body {
29
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
30
- background: var(--bg);
31
- color: var(--text);
32
- min-height: 100vh;
33
- line-height: 1.7;
34
- }
35
-
36
- /* Layout */
37
- main {
38
- max-width: 1100px;
39
- margin: 0 auto;
40
- padding: 2.5rem 2rem 6rem;
41
- }
42
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  .article-layout {
44
- display: grid;
45
- grid-template-columns: 1fr 280px;
46
- gap: 2.5rem;
47
- align-items: start;
48
  }
49
 
50
  .article-aside {
51
- position: sticky;
52
- top: 2.5rem;
53
  }
54
 
55
  .article-figure {
56
- border-radius: var(--radius);
57
- overflow: hidden;
58
- border: 1px solid var(--border);
59
- background: var(--surface);
60
- }
61
-
62
- .article-figure img {
63
- width: 100%;
64
- display: block;
65
- }
66
-
67
- .article-figure figcaption {
68
- padding: 10px 14px;
69
- font-size: 0.78rem;
70
- line-height: 1.5;
71
- color: var(--text-muted);
72
- }
73
-
74
- @media (max-width: 768px) {
75
- .article-layout {
76
- grid-template-columns: 1fr;
77
- }
78
-
79
- .article-aside {
80
- position: static;
81
- order: -1;
82
- }
83
-
84
- .article-figure {
85
- max-width: 320px;
86
- }
87
- }
88
-
89
- /* Article typography */
90
- .article-content h1 {
91
- font-size: 2.2rem;
92
- font-weight: 800;
93
- letter-spacing: -0.03em;
94
- margin-bottom: 1.5rem;
95
- line-height: 1.2;
96
- background: linear-gradient(135deg, var(--text), var(--text-muted));
97
- -webkit-background-clip: text;
98
- -webkit-text-fill-color: transparent;
99
- background-clip: text;
100
- }
101
-
102
- .article-content h2 {
103
- font-size: 1.5rem;
104
- font-weight: 700;
105
- letter-spacing: -0.02em;
106
- margin-top: 2.5rem;
107
- margin-bottom: 1rem;
108
- padding-top: 1.5rem;
109
- border-top: 1px solid var(--border);
110
- }
111
-
112
- .article-content h3 {
113
- font-size: 1.15rem;
114
- font-weight: 600;
115
- margin-top: 2rem;
116
- margin-bottom: 0.75rem;
117
- color: var(--text-muted);
118
- }
119
-
120
- .article-content h4 {
121
- font-size: 1.02rem;
122
- font-weight: 600;
123
- margin-top: 1.5rem;
124
- margin-bottom: 0.5rem;
125
- color: var(--text-muted);
126
- }
127
-
128
- .article-content p {
129
- margin-bottom: 1.25rem;
130
- font-size: 1.02rem;
131
- position: relative;
132
- }
133
-
134
- /* Hoverable paragraphs with summarize button */
135
- .article-content p.hoverable {
136
- padding: 10px 14px;
137
- border-radius: 8px;
138
- transition: background 0.2s;
139
- }
140
-
141
- .article-content p.hoverable:hover {
142
- background: var(--surface);
143
- }
144
-
145
- .summarize-btn {
146
- position: absolute;
147
- top: 8px;
148
- right: 8px;
149
- display: flex;
150
- align-items: center;
151
- gap: 5px;
152
- padding: 5px 12px;
153
- border: 1px solid var(--border);
154
- border-radius: 7px;
155
- background: var(--surface);
156
- color: var(--accent-light);
157
- font-size: 0.75rem;
158
- font-weight: 600;
159
- cursor: pointer;
160
- opacity: 0;
161
- pointer-events: none;
162
- transition: all 0.15s;
163
- white-space: nowrap;
164
- }
165
-
166
- .summarize-btn svg {
167
- flex-shrink: 0;
168
- }
169
-
170
- .article-content p.hoverable:hover .summarize-btn {
171
- opacity: 1;
172
- pointer-events: auto;
173
- }
174
-
175
- .summarize-btn:hover {
176
- background: var(--surface-hover);
177
- border-color: var(--accent);
178
- box-shadow: 0 0 12px var(--accent-glow);
179
- }
180
-
181
- /* Summary result block */
182
- .summary-block {
183
- background: rgba(99, 102, 241, 0.06);
184
- border-radius: 8px;
185
- padding: 14px 16px;
186
- margin: 1.25rem 0;
187
- }
188
-
189
- .summary-block .label {
190
- display: inline-flex;
191
- align-items: center;
192
- gap: 5px;
193
- font-size: 0.68rem;
194
- font-weight: 700;
195
- text-transform: uppercase;
196
- letter-spacing: 0.08em;
197
- color: var(--accent-light);
198
- margin-bottom: 4px;
199
- }
200
-
201
- .summary-block .output {
202
- margin: 0;
203
- font-size: 1.02rem;
204
- line-height: 1.7;
205
- }
206
-
207
- .summary-block .stats {
208
- display: inline-flex;
209
- align-items: center;
210
- gap: 6px;
211
- font-size: 0.72rem;
212
- color: var(--text-muted);
213
- margin-top: 8px;
214
- padding-top: 8px;
215
- border-top: 1px solid var(--border);
216
- }
217
-
218
- .summary-block .stats span {
219
- color: var(--accent-light);
220
- font-weight: 600;
221
- }
222
-
223
- /* Spinner */
224
- @keyframes spin {
225
- to { transform: rotate(360deg); }
226
- }
227
-
228
- .spinner {
229
- display: inline-block;
230
- width: 10px;
231
- height: 10px;
232
- border: 1.5px solid var(--accent-glow);
233
- border-top-color: var(--accent-light);
234
- border-radius: 50%;
235
- animation: spin 0.6s linear infinite;
236
- }
237
-
238
- /* Chat widget */
239
- .chat-widget {
240
- position: fixed;
241
- bottom: 24px;
242
- right: 24px;
243
- z-index: 150;
244
- display: flex;
245
- flex-direction: column;
246
- align-items: flex-end;
247
- gap: 10px;
248
- width: 400px;
249
- max-width: calc(100vw - 48px);
250
- }
251
-
252
- /* Response bubble */
253
- .chat-response {
254
- width: 100%;
255
- background: var(--surface);
256
- border: 1px solid var(--border);
257
- border-radius: 14px;
258
- padding: 16px;
259
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
260
- display: none;
261
- animation: bubbleIn 0.2s ease-out;
262
- max-height: 50vh;
263
- overflow-y: auto;
264
- }
265
-
266
- @keyframes bubbleIn {
267
- from { opacity: 0; transform: translateY(8px) scale(0.97); }
268
- to { opacity: 1; transform: translateY(0) scale(1); }
269
- }
270
-
271
- .chat-response.visible {
272
- display: block;
273
- }
274
-
275
- .chat-response-header {
276
- display: flex;
277
- align-items: center;
278
- justify-content: space-between;
279
- margin-bottom: 8px;
280
- }
281
-
282
- .chat-response .chat-label {
283
- display: inline-flex;
284
- align-items: center;
285
- gap: 5px;
286
- font-size: 0.65rem;
287
- font-weight: 700;
288
- text-transform: uppercase;
289
- letter-spacing: 0.08em;
290
- color: var(--accent-light);
291
- }
292
-
293
- .chat-close {
294
- width: 24px;
295
- height: 24px;
296
- border: none;
297
- border-radius: 6px;
298
- background: transparent;
299
- color: var(--text-muted);
300
- cursor: pointer;
301
- display: flex;
302
- align-items: center;
303
- justify-content: center;
304
- transition: all 0.15s;
305
- flex-shrink: 0;
306
- }
307
-
308
- .chat-close:hover {
309
- background: var(--surface-hover);
310
- color: var(--text);
311
- }
312
-
313
- .chat-response .chat-question {
314
- font-size: 0.8rem;
315
- color: var(--text-muted);
316
- margin-bottom: 8px;
317
- padding-bottom: 8px;
318
- border-bottom: 1px solid var(--border);
319
- font-style: italic;
320
- }
321
-
322
- .chat-response .chat-text {
323
- margin: 0;
324
- font-size: 0.9rem;
325
- line-height: 1.6;
326
- }
327
-
328
- .chat-response .chat-stats {
329
- font-size: 0.7rem;
330
- color: var(--text-muted);
331
- margin-top: 8px;
332
- padding-top: 8px;
333
- border-top: 1px solid var(--border);
334
- }
335
-
336
- .chat-response .chat-stats span {
337
- color: var(--accent-light);
338
- font-weight: 600;
339
- }
340
-
341
- /* Input row */
342
- .chat-input-row {
343
- display: flex;
344
- gap: 8px;
345
- align-items: center;
346
- width: 100%;
347
- background: var(--surface);
348
- border: 1px solid var(--border);
349
- border-radius: 12px;
350
- padding: 6px 6px 6px 16px;
351
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
352
- transition: border-color 0.15s;
353
- }
354
-
355
- .chat-input-row:focus-within {
356
- border-color: var(--accent);
357
- }
358
-
359
- .chat-input {
360
- flex: 1;
361
- padding: 6px 0;
362
- border: none;
363
- background: transparent;
364
- color: var(--text);
365
- font-size: 0.9rem;
366
- font-family: inherit;
367
- outline: none;
368
- }
369
-
370
- .chat-input::placeholder {
371
- color: var(--text-muted);
372
- }
373
-
374
- .chat-input:disabled {
375
- opacity: 0.5;
376
- }
377
-
378
- .chat-send {
379
- width: 34px;
380
- height: 34px;
381
- border: none;
382
- border-radius: 8px;
383
- background: var(--accent);
384
- color: white;
385
- cursor: pointer;
386
- display: flex;
387
- align-items: center;
388
- justify-content: center;
389
- flex-shrink: 0;
390
- transition: all 0.15s;
391
- }
392
-
393
- .chat-send:hover:not(:disabled) {
394
- background: var(--accent-light);
395
- box-shadow: 0 0 16px var(--accent-glow);
396
- }
397
-
398
- .chat-send:disabled {
399
- opacity: 0.4;
400
- cursor: not-allowed;
401
- }
402
-
403
- /* Loading overlay */
404
- .loading-overlay {
405
- position: fixed;
406
- inset: 0;
407
- background: rgba(15, 17, 23, 0.6);
408
- backdrop-filter: blur(4px);
409
- z-index: 200;
410
- display: flex;
411
- align-items: center;
412
- justify-content: center;
413
- transition: opacity 0.4s;
414
- }
415
-
416
- .loading-overlay.hidden {
417
- opacity: 0;
418
- pointer-events: none;
419
- }
420
-
421
- .loading-card {
422
- background: var(--surface);
423
- border: 1px solid var(--border);
424
- border-radius: 20px;
425
- padding: 3rem 3rem 2.5rem;
426
- text-align: center;
427
- max-width: 420px;
428
- width: 100%;
429
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
430
- }
431
-
432
- .loading-card h2 {
433
- font-size: 1.2rem;
434
- font-weight: 700;
435
- margin-bottom: 6px;
436
- }
437
-
438
- .loading-card p {
439
- font-size: 0.85rem;
440
- color: var(--text-muted);
441
- margin-bottom: 28px;
442
- }
443
-
444
- .loading-progress {
445
- height: 6px;
446
- background: var(--bg);
447
- border-radius: 3px;
448
- overflow: hidden;
449
- }
450
-
451
- .loading-progress-fill {
452
- height: 100%;
453
- background: linear-gradient(90deg, var(--accent), #a78bfa);
454
- border-radius: 3px;
455
- transition: width 0.3s;
456
- width: 0%;
457
- }
458
-
459
- .loading-detail {
460
- font-size: 0.78rem;
461
- color: var(--text-muted);
462
- margin-top: 14px;
463
- }
464
  </style>
465
- </head>
466
 
467
- <body>
468
  <main>
469
- <div class="article-layout">
470
- <div class="article-content" id="article"></div>
471
- <aside class="article-aside">
472
- <figure class="article-figure">
473
- <img src="./assets/artemis.webp" alt="SLS rocket for Artemis II at Launch Complex 39B" />
474
- <figcaption>The Space Launch System (SLS) rocket for Artemis II
475
- at Launch Complex 39B in March 2026</figcaption>
476
- </figure>
477
- </aside>
478
- </div>
 
479
  </main>
480
 
481
  <div class="chat-widget">
482
- <div class="chat-response" id="chat-response">
483
- <div class="chat-response-header">
484
- <div class="chat-label" id="chat-label"><span class="spinner"></span> Thinking</div>
485
- <button class="chat-close" id="chat-close">
486
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
487
- stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
488
- <path d="M18 6L6 18M6 6l12 12" />
489
- </svg>
490
- </button>
491
- </div>
492
- <div class="chat-question" id="chat-question"></div>
493
- <p class="chat-text" id="chat-text"></p>
494
- <div class="chat-stats" id="chat-stats"></div>
495
- </div>
496
- <div class="chat-input-row">
497
- <input type="text" class="chat-input" id="chat-input"
498
- placeholder="Ask about this article..." autocomplete="off" disabled />
499
- <button class="chat-send" id="chat-send" disabled>
500
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
501
- stroke-linecap="round" stroke-linejoin="round">
502
- <path d="M5 12h14M12 5l7 7-7 7" />
503
- </svg>
504
- </button>
505
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  </div>
507
 
508
  <div class="loading-overlay" id="loading-overlay">
509
- <div class="loading-card">
510
- <h2>Loading LFM2.5-350M</h2>
511
- <p>Downloading model and compiling WebGPU shaders</p>
512
- <div class="loading-progress">
513
- <div class="loading-progress-fill" id="loading-progress-fill"></div>
514
- </div>
515
- <div class="loading-detail" id="loading-detail">Starting...</div>
516
  </div>
 
 
517
  </div>
518
 
519
  <script type="module">
520
- import { pipeline, TextStreamer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0";
521
-
522
- 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>`;
523
-
524
- // ── State ──
525
- let generator = null;
526
- let isGenerating = false;
527
- let articleSource = "";
528
-
529
- // ── DOM refs ──
530
- const article = document.getElementById("article");
531
- const loadingOverlay = document.getElementById("loading-overlay");
532
- const loadingProgressFill = document.getElementById("loading-progress-fill");
533
- const loadingDetail = document.getElementById("loading-detail");
534
- const chatInput = document.getElementById("chat-input");
535
- const chatSend = document.getElementById("chat-send");
536
- const chatResponse = document.getElementById("chat-response");
537
- const chatLabel = document.getElementById("chat-label");
538
- const chatQuestion = document.getElementById("chat-question");
539
- const chatText = document.getElementById("chat-text");
540
- const chatStats = document.getElementById("chat-stats");
541
- const chatClose = document.getElementById("chat-close");
542
-
543
- // ── Markdown β†’ HTML ──
544
- function markdownToHTML(md) {
545
- const HEADINGS = [
546
- { prefix: "#### ", tag: "h4" },
547
- { prefix: "### ", tag: "h3" },
548
- { prefix: "## ", tag: "h2" },
549
- { prefix: "# ", tag: "h1" },
550
- ];
551
-
552
- const lines = md.split("\n");
553
- let html = "";
554
- let buffer = "";
555
-
556
- const flushBuffer = () => {
557
- if (buffer) {
558
- html += `<p>${buffer.trim()}</p>`;
559
- buffer = "";
560
- }
561
- };
562
-
563
- for (const line of lines) {
564
- const heading = HEADINGS.find((h) => line.startsWith(h.prefix));
565
- if (heading) {
566
- flushBuffer();
567
- html += `<${heading.tag}>${line.slice(heading.prefix.length)}</${heading.tag}>`;
568
- } else if (line.trim() === "") {
569
- flushBuffer();
570
- } else {
571
- buffer += (buffer ? " " : "") + line.replace(/\[\d+\]/g, "");
572
- }
573
- }
574
- flushBuffer();
575
- return html;
576
- }
577
-
578
- // ── Attach summarize buttons to all <p> in the article ──
579
- function attachSummarizeButtons() {
580
- for (const p of article.querySelectorAll("p")) {
581
- if (p.classList.contains("hoverable")) continue;
582
- p.classList.add("hoverable");
583
-
584
- const btn = document.createElement("button");
585
- btn.className = "summarize-btn";
586
- btn.innerHTML = `${SUMMARIZE_SVG} Summarize`;
587
- btn.onclick = (e) => {
588
- e.stopPropagation();
589
- summarizeParagraph(p);
590
- };
591
- p.appendChild(btn);
592
- }
593
- }
594
-
595
- // ── Load article + model ──
596
- async function loadArticle() {
597
- const res = await fetch("data.txt");
598
- articleSource = await res.text();
599
- article.innerHTML = markdownToHTML(articleSource);
600
- }
601
-
602
- async function loadModel() {
603
- loadingDetail.textContent = "Downloading model...";
604
-
605
- generator = await pipeline(
606
- "text-generation",
607
- "onnx-community/LFM2.5-350M-ONNX",
608
- {
609
- dtype: "q4",
610
- device: "webgpu",
611
- progress_callback: (progress) => {
612
- if (progress.status === "progress_total") {
613
- loadingProgressFill.style.width = `${progress.progress ?? 0}%`;
614
- }
615
- },
616
- },
617
- );
618
-
619
- loadingOverlay.classList.add("hidden");
620
- chatInput.disabled = false;
621
- chatSend.disabled = false;
622
- chatInput.focus();
623
- }
624
-
625
- // ── Summarize a paragraph ──
626
- async function summarizeParagraph(p) {
627
- if (!generator || isGenerating) return;
628
- isGenerating = true;
629
-
630
- const originalText = p.textContent.trim();
631
- const originalWordCount = originalText.split(/\s+/).length;
632
-
633
- const block = document.createElement("div");
634
- block.className = "summary-block";
635
-
636
- const label = document.createElement("div");
637
- label.className = "label";
638
- label.innerHTML = `<span class="spinner"></span> Summarizing`;
639
- block.appendChild(label);
640
-
641
- const output = document.createElement("p");
642
- output.className = "output";
643
- block.appendChild(output);
644
-
645
- p.replaceWith(block);
646
-
647
- const t0 = performance.now();
648
- let tFirstToken = null;
649
- let tokenCount = 0;
650
-
651
- const streamer = new TextStreamer(generator.tokenizer, {
652
- skip_prompt: true,
653
- skip_special_tokens: true,
654
- callback_function: (text) => {
655
- tFirstToken ??= performance.now();
656
- tokenCount++;
657
- output.textContent += text;
658
- },
659
- });
660
-
661
- try {
662
- await generator(
663
- [
664
- // { role: "system", content: "You are a concise summarizer. Summarize the given text in a brief paragraph, preserving key facts. Output only the summary, nothing else." },
665
- { role: "user", content: `Summarize this:\n\n${originalText}` },
666
- ],
667
- { max_new_tokens: 512, do_sample: false, streamer },
668
- );
669
- } catch (err) {
670
- output.textContent = "Error: " + err.message;
671
- }
672
-
673
- const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
674
- const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
675
- const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
676
- const summaryWordCount = output.textContent.split(/\s+/).length;
677
-
678
- label.textContent = "Summary";
679
-
680
- const stats = document.createElement("div");
681
- stats.className = "stats";
682
- stats.innerHTML = `<span>${originalWordCount}</span> words &rarr; <span>${summaryWordCount}</span> words &middot; ${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
683
- block.appendChild(stats);
684
-
685
- isGenerating = false;
686
- }
687
 
688
- // ── Chat: ask a question about the article ──
689
- async function askQuestion() {
690
- const question = chatInput.value.trim();
691
- if (!question || !generator || isGenerating) return;
692
-
693
- isGenerating = true;
694
- chatInput.disabled = true;
695
- chatSend.disabled = true;
696
-
697
- // Reset response area
698
- chatQuestion.textContent = question;
699
- chatText.textContent = "";
700
- chatStats.textContent = "";
701
- chatLabel.innerHTML = `<span class="spinner"></span> Thinking`;
702
- chatResponse.classList.add("visible");
703
- chatInput.value = "";
704
-
705
- const t0 = performance.now();
706
- let tFirstToken = null;
707
- let tokenCount = 0;
708
-
709
- const streamer = new TextStreamer(generator.tokenizer, {
710
- skip_prompt: true,
711
- skip_special_tokens: true,
712
- callback_function: (text) => {
713
- tFirstToken ??= performance.now();
714
- tokenCount++;
715
- chatText.textContent += text;
716
- },
717
- });
718
-
719
- try {
720
- await generator(
721
- [
722
- { 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}` },
723
- { role: "user", content: question },
724
- ],
725
- { max_new_tokens: 512, do_sample: false, streamer },
726
- );
727
- } catch (err) {
728
- chatText.textContent = "Error: " + err.message;
 
 
 
 
 
 
 
729
  }
 
 
730
 
731
- const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
732
- const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
733
- const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
734
-
735
- chatLabel.textContent = "Answer";
736
- chatStats.innerHTML = `${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
737
-
738
- isGenerating = false;
739
- chatInput.disabled = false;
740
- chatSend.disabled = false;
741
- chatInput.focus();
742
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
 
744
- chatSend.onclick = askQuestion;
745
- chatInput.addEventListener("keydown", (e) => {
746
- if (e.key === "Enter" && !e.shiftKey) {
747
- e.preventDefault();
748
- askQuestion();
749
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  });
751
- chatClose.onclick = () => {
752
- if (!isGenerating) chatResponse.classList.remove("visible");
753
- };
754
 
755
- // ── Init ──
756
- await loadArticle();
757
- attachSummarizeButtons();
758
- loadModel();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
  </script>
760
- </body>
761
-
762
  </html>
 
1
+ <!doctype html>
2
  <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
6
  <title>LFM2.5 Summarizer | WebGPU</title>
7
  <style>
8
+ :root {
9
+ --bg: #0f1117;
10
+ --surface: #1a1d27;
11
+ --surface-hover: #222632;
12
+ --border: #2a2e3b;
13
+ --text: #e4e4e7;
14
+ --text-muted: #8b8fa3;
15
+ --accent: #6366f1;
16
+ --accent-glow: rgba(99, 102, 241, 0.25);
17
+ --accent-light: #818cf8;
18
+ --radius: 12px;
19
+ }
20
+
21
+ * {
22
+ margin: 0;
23
+ padding: 0;
24
+ box-sizing: border-box;
25
+ }
26
+
27
+ body {
28
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ min-height: 100vh;
32
+ line-height: 1.7;
33
+ }
34
+
35
+ /* Layout */
36
+ main {
37
+ max-width: 1100px;
38
+ margin: 0 auto;
39
+ padding: 2.5rem 2rem 6rem;
40
+ }
41
+
42
+ .article-layout {
43
+ display: grid;
44
+ grid-template-columns: 1fr 280px;
45
+ gap: 2.5rem;
46
+ align-items: start;
47
+ }
48
+
49
+ .article-aside {
50
+ position: sticky;
51
+ top: 2.5rem;
52
+ }
53
+
54
+ .article-figure {
55
+ border-radius: var(--radius);
56
+ overflow: hidden;
57
+ border: 1px solid var(--border);
58
+ background: var(--surface);
59
+ }
60
+
61
+ .article-figure img {
62
+ width: 100%;
63
+ display: block;
64
+ }
65
+
66
+ .article-figure figcaption {
67
+ padding: 10px 14px;
68
+ font-size: 0.78rem;
69
+ line-height: 1.5;
70
+ color: var(--text-muted);
71
+ }
72
+
73
+ @media (max-width: 768px) {
74
  .article-layout {
75
+ grid-template-columns: 1fr;
 
 
 
76
  }
77
 
78
  .article-aside {
79
+ position: static;
80
+ order: -1;
81
  }
82
 
83
  .article-figure {
84
+ max-width: 320px;
85
+ }
86
+ }
87
+
88
+ /* Article typography */
89
+ .article-content h1 {
90
+ font-size: 2.2rem;
91
+ font-weight: 800;
92
+ letter-spacing: -0.03em;
93
+ margin-bottom: 1.5rem;
94
+ line-height: 1.2;
95
+ background: linear-gradient(135deg, var(--text), var(--text-muted));
96
+ -webkit-background-clip: text;
97
+ -webkit-text-fill-color: transparent;
98
+ background-clip: text;
99
+ }
100
+
101
+ .article-content h2 {
102
+ font-size: 1.5rem;
103
+ font-weight: 700;
104
+ letter-spacing: -0.02em;
105
+ margin-top: 2.5rem;
106
+ margin-bottom: 1rem;
107
+ padding-top: 1.5rem;
108
+ border-top: 1px solid var(--border);
109
+ }
110
+
111
+ .article-content h3 {
112
+ font-size: 1.15rem;
113
+ font-weight: 600;
114
+ margin-top: 2rem;
115
+ margin-bottom: 0.75rem;
116
+ color: var(--text-muted);
117
+ }
118
+
119
+ .article-content h4 {
120
+ font-size: 1.02rem;
121
+ font-weight: 600;
122
+ margin-top: 1.5rem;
123
+ margin-bottom: 0.5rem;
124
+ color: var(--text-muted);
125
+ }
126
+
127
+ .article-content p {
128
+ margin-bottom: 1.25rem;
129
+ font-size: 1.02rem;
130
+ position: relative;
131
+ }
132
+
133
+ /* Hoverable paragraphs with summarize button */
134
+ .article-content p.hoverable {
135
+ padding: 10px 14px;
136
+ border-radius: 8px;
137
+ transition: background 0.2s;
138
+ }
139
+
140
+ .article-content p.hoverable:hover {
141
+ background: var(--surface);
142
+ }
143
+
144
+ .summarize-btn {
145
+ position: absolute;
146
+ top: 8px;
147
+ right: 8px;
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 5px;
151
+ padding: 5px 12px;
152
+ border: 1px solid var(--border);
153
+ border-radius: 7px;
154
+ background: var(--surface);
155
+ color: var(--accent-light);
156
+ font-size: 0.75rem;
157
+ font-weight: 600;
158
+ cursor: pointer;
159
+ opacity: 0;
160
+ pointer-events: none;
161
+ transition: all 0.15s;
162
+ white-space: nowrap;
163
+ }
164
+
165
+ .summarize-btn svg {
166
+ flex-shrink: 0;
167
+ }
168
+
169
+ .article-content p.hoverable:hover .summarize-btn {
170
+ opacity: 1;
171
+ pointer-events: auto;
172
+ }
173
+
174
+ .summarize-btn:hover {
175
+ background: var(--surface-hover);
176
+ border-color: var(--accent);
177
+ box-shadow: 0 0 12px var(--accent-glow);
178
+ }
179
+
180
+ /* Summary result block */
181
+ .summary-block {
182
+ background: rgba(99, 102, 241, 0.06);
183
+ border-radius: 8px;
184
+ padding: 14px 16px;
185
+ margin: 1.25rem 0;
186
+ }
187
+
188
+ .summary-block .label {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ gap: 5px;
192
+ font-size: 0.68rem;
193
+ font-weight: 700;
194
+ text-transform: uppercase;
195
+ letter-spacing: 0.08em;
196
+ color: var(--accent-light);
197
+ margin-bottom: 4px;
198
+ }
199
+
200
+ .summary-block .output {
201
+ margin: 0;
202
+ font-size: 1.02rem;
203
+ line-height: 1.7;
204
+ }
205
+
206
+ .summary-block .stats {
207
+ display: inline-flex;
208
+ align-items: center;
209
+ gap: 6px;
210
+ font-size: 0.72rem;
211
+ color: var(--text-muted);
212
+ margin-top: 8px;
213
+ padding-top: 8px;
214
+ border-top: 1px solid var(--border);
215
+ }
216
+
217
+ .summary-block .stats span {
218
+ color: var(--accent-light);
219
+ font-weight: 600;
220
+ }
221
+
222
+ /* Spinner */
223
+ @keyframes spin {
224
+ to {
225
+ transform: rotate(360deg);
226
+ }
227
+ }
228
+
229
+ .spinner {
230
+ display: inline-block;
231
+ width: 10px;
232
+ height: 10px;
233
+ border: 1.5px solid var(--accent-glow);
234
+ border-top-color: var(--accent-light);
235
+ border-radius: 50%;
236
+ animation: spin 0.6s linear infinite;
237
+ }
238
+
239
+ /* Chat widget */
240
+ .chat-widget {
241
+ position: fixed;
242
+ bottom: 24px;
243
+ right: 24px;
244
+ z-index: 150;
245
+ display: flex;
246
+ flex-direction: column;
247
+ align-items: flex-end;
248
+ gap: 10px;
249
+ width: 400px;
250
+ max-width: calc(100vw - 48px);
251
+ }
252
+
253
+ /* Response bubble */
254
+ .chat-response {
255
+ width: 100%;
256
+ background: var(--surface);
257
+ border: 1px solid var(--border);
258
+ border-radius: 14px;
259
+ padding: 16px;
260
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
261
+ display: none;
262
+ animation: bubbleIn 0.2s ease-out;
263
+ max-height: 50vh;
264
+ overflow-y: auto;
265
+ }
266
+
267
+ @keyframes bubbleIn {
268
+ from {
269
+ opacity: 0;
270
+ transform: translateY(8px) scale(0.97);
271
+ }
272
+ to {
273
+ opacity: 1;
274
+ transform: translateY(0) scale(1);
275
+ }
276
+ }
277
+
278
+ .chat-response.visible {
279
+ display: block;
280
+ }
281
+
282
+ .chat-response-header {
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: space-between;
286
+ margin-bottom: 8px;
287
+ }
288
+
289
+ .chat-response .chat-label {
290
+ display: inline-flex;
291
+ align-items: center;
292
+ gap: 5px;
293
+ font-size: 0.65rem;
294
+ font-weight: 700;
295
+ text-transform: uppercase;
296
+ letter-spacing: 0.08em;
297
+ color: var(--accent-light);
298
+ }
299
+
300
+ .chat-close {
301
+ width: 24px;
302
+ height: 24px;
303
+ border: none;
304
+ border-radius: 6px;
305
+ background: transparent;
306
+ color: var(--text-muted);
307
+ cursor: pointer;
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ transition: all 0.15s;
312
+ flex-shrink: 0;
313
+ }
314
+
315
+ .chat-close:hover {
316
+ background: var(--surface-hover);
317
+ color: var(--text);
318
+ }
319
+
320
+ .chat-response .chat-question {
321
+ font-size: 0.8rem;
322
+ color: var(--text-muted);
323
+ margin-bottom: 8px;
324
+ padding-bottom: 8px;
325
+ border-bottom: 1px solid var(--border);
326
+ font-style: italic;
327
+ }
328
+
329
+ .chat-response .chat-text {
330
+ margin: 0;
331
+ font-size: 0.9rem;
332
+ line-height: 1.6;
333
+ }
334
+
335
+ .chat-response .chat-stats {
336
+ font-size: 0.7rem;
337
+ color: var(--text-muted);
338
+ margin-top: 8px;
339
+ padding-top: 8px;
340
+ border-top: 1px solid var(--border);
341
+ }
342
+
343
+ .chat-response .chat-stats span {
344
+ color: var(--accent-light);
345
+ font-weight: 600;
346
+ }
347
+
348
+ /* Input row */
349
+ .chat-input-row {
350
+ display: flex;
351
+ gap: 8px;
352
+ align-items: center;
353
+ width: 100%;
354
+ background: var(--surface);
355
+ border: 1px solid var(--border);
356
+ border-radius: 12px;
357
+ padding: 6px 6px 6px 16px;
358
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
359
+ transition: border-color 0.15s;
360
+ }
361
+
362
+ .chat-input-row:focus-within {
363
+ border-color: var(--accent);
364
+ }
365
+
366
+ .chat-input {
367
+ flex: 1;
368
+ padding: 6px 0;
369
+ border: none;
370
+ background: transparent;
371
+ color: var(--text);
372
+ font-size: 0.9rem;
373
+ font-family: inherit;
374
+ outline: none;
375
+ }
376
+
377
+ .chat-input::placeholder {
378
+ color: var(--text-muted);
379
+ }
380
+
381
+ .chat-input:disabled {
382
+ opacity: 0.5;
383
+ }
384
+
385
+ .chat-send {
386
+ width: 34px;
387
+ height: 34px;
388
+ border: none;
389
+ border-radius: 8px;
390
+ background: var(--accent);
391
+ color: white;
392
+ cursor: pointer;
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ flex-shrink: 0;
397
+ transition: all 0.15s;
398
+ }
399
+
400
+ .chat-send:hover:not(:disabled) {
401
+ background: var(--accent-light);
402
+ box-shadow: 0 0 16px var(--accent-glow);
403
+ }
404
+
405
+ .chat-send:disabled {
406
+ opacity: 0.4;
407
+ cursor: not-allowed;
408
+ }
409
+
410
+ /* Loading overlay */
411
+ .loading-overlay {
412
+ position: fixed;
413
+ inset: 0;
414
+ background: rgba(15, 17, 23, 0.6);
415
+ backdrop-filter: blur(4px);
416
+ z-index: 200;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ transition: opacity 0.4s;
421
+ }
422
+
423
+ .loading-overlay.hidden {
424
+ opacity: 0;
425
+ pointer-events: none;
426
+ }
427
+
428
+ .loading-card {
429
+ background: var(--surface);
430
+ border: 1px solid var(--border);
431
+ border-radius: 20px;
432
+ padding: 3rem 3rem 2.5rem;
433
+ text-align: center;
434
+ max-width: 420px;
435
+ width: 100%;
436
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
437
+ }
438
+
439
+ .loading-card h2 {
440
+ font-size: 1.2rem;
441
+ font-weight: 700;
442
+ margin-bottom: 6px;
443
+ }
444
+
445
+ .loading-card p {
446
+ font-size: 0.85rem;
447
+ color: var(--text-muted);
448
+ margin-bottom: 28px;
449
+ }
450
+
451
+ .loading-progress {
452
+ height: 6px;
453
+ background: var(--bg);
454
+ border-radius: 3px;
455
+ overflow: hidden;
456
+ }
457
+
458
+ .loading-progress-fill {
459
+ height: 100%;
460
+ background: linear-gradient(90deg, var(--accent), #a78bfa);
461
+ border-radius: 3px;
462
+ transition: width 0.3s;
463
+ width: 0%;
464
+ }
465
+
466
+ .loading-detail {
467
+ font-size: 0.78rem;
468
+ color: var(--text-muted);
469
+ margin-top: 14px;
470
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  </style>
472
+ </head>
473
 
474
+ <body>
475
  <main>
476
+ <div class="article-layout">
477
+ <div class="article-content" id="article"></div>
478
+ <aside class="article-aside">
479
+ <figure class="article-figure">
480
+ <img src="./assets/artemis.webp" alt="SLS rocket for Artemis II at Launch Complex 39B" />
481
+ <figcaption>
482
+ The Space Launch System (SLS) rocket for Artemis II at Launch Complex 39B in March 2026
483
+ </figcaption>
484
+ </figure>
485
+ </aside>
486
+ </div>
487
  </main>
488
 
489
  <div class="chat-widget">
490
+ <div class="chat-response" id="chat-response">
491
+ <div class="chat-response-header">
492
+ <div class="chat-label" id="chat-label"><span class="spinner"></span> Thinking</div>
493
+ <button class="chat-close" id="chat-close">
494
+ <svg
495
+ width="14"
496
+ height="14"
497
+ viewBox="0 0 24 24"
498
+ fill="none"
499
+ stroke="currentColor"
500
+ stroke-width="2.5"
501
+ stroke-linecap="round"
502
+ stroke-linejoin="round"
503
+ >
504
+ <path d="M18 6L6 18M6 6l12 12" />
505
+ </svg>
506
+ </button>
 
 
 
 
 
 
507
  </div>
508
+ <div class="chat-question" id="chat-question"></div>
509
+ <p class="chat-text" id="chat-text"></p>
510
+ <div class="chat-stats" id="chat-stats"></div>
511
+ </div>
512
+ <div class="chat-input-row">
513
+ <input
514
+ type="text"
515
+ class="chat-input"
516
+ id="chat-input"
517
+ placeholder="Ask about this article..."
518
+ autocomplete="off"
519
+ disabled
520
+ />
521
+ <button class="chat-send" id="chat-send" disabled>
522
+ <svg
523
+ width="16"
524
+ height="16"
525
+ viewBox="0 0 24 24"
526
+ fill="none"
527
+ stroke="currentColor"
528
+ stroke-width="2.5"
529
+ stroke-linecap="round"
530
+ stroke-linejoin="round"
531
+ >
532
+ <path d="M5 12h14M12 5l7 7-7 7" />
533
+ </svg>
534
+ </button>
535
+ </div>
536
  </div>
537
 
538
  <div class="loading-overlay" id="loading-overlay">
539
+ <div class="loading-card">
540
+ <h2>Loading LFM2.5-350M</h2>
541
+ <p>Downloading model and compiling WebGPU shaders</p>
542
+ <div class="loading-progress">
543
+ <div class="loading-progress-fill" id="loading-progress-fill"></div>
 
 
544
  </div>
545
+ <div class="loading-detail" id="loading-detail">Starting...</div>
546
+ </div>
547
  </div>
548
 
549
  <script type="module">
550
+ import { pipeline, TextStreamer } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.0.0";
551
+
552
+ 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>`;
553
+
554
+ // ── State ──
555
+ let generator = null;
556
+ let isGenerating = false;
557
+ let articleSource = "";
558
+
559
+ // ── DOM refs ──
560
+ const article = document.getElementById("article");
561
+ const loadingOverlay = document.getElementById("loading-overlay");
562
+ const loadingProgressFill = document.getElementById("loading-progress-fill");
563
+ const loadingDetail = document.getElementById("loading-detail");
564
+ const chatInput = document.getElementById("chat-input");
565
+ const chatSend = document.getElementById("chat-send");
566
+ const chatResponse = document.getElementById("chat-response");
567
+ const chatLabel = document.getElementById("chat-label");
568
+ const chatQuestion = document.getElementById("chat-question");
569
+ const chatText = document.getElementById("chat-text");
570
+ const chatStats = document.getElementById("chat-stats");
571
+ const chatClose = document.getElementById("chat-close");
572
+
573
+ // ── Markdown β†’ HTML ──
574
+ function markdownToHTML(md) {
575
+ const HEADINGS = [
576
+ { prefix: "#### ", tag: "h4" },
577
+ { prefix: "### ", tag: "h3" },
578
+ { prefix: "## ", tag: "h2" },
579
+ { prefix: "# ", tag: "h1" },
580
+ ];
581
+
582
+ const lines = md.split("\n");
583
+ let html = "";
584
+ let buffer = "";
585
+
586
+ const flushBuffer = () => {
587
+ if (buffer) {
588
+ html += `<p>${buffer.trim()}</p>`;
589
+ buffer = "";
590
+ }
591
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
 
593
+ for (const line of lines) {
594
+ const heading = HEADINGS.find((h) => line.startsWith(h.prefix));
595
+ if (heading) {
596
+ flushBuffer();
597
+ html += `<${heading.tag}>${line.slice(heading.prefix.length)}</${heading.tag}>`;
598
+ } else if (line.trim() === "") {
599
+ flushBuffer();
600
+ } else {
601
+ buffer += (buffer ? " " : "") + line.replace(/\[\d+\]/g, "");
602
+ }
603
+ }
604
+ flushBuffer();
605
+ return html;
606
+ }
607
+
608
+ // ── Attach summarize buttons to all <p> in the article ──
609
+ function attachSummarizeButtons() {
610
+ for (const p of article.querySelectorAll("p")) {
611
+ if (p.classList.contains("hoverable")) continue;
612
+ p.classList.add("hoverable");
613
+
614
+ const btn = document.createElement("button");
615
+ btn.className = "summarize-btn";
616
+ btn.innerHTML = `${SUMMARIZE_SVG} Summarize`;
617
+ btn.onclick = (e) => {
618
+ e.stopPropagation();
619
+ summarizeParagraph(p);
620
+ };
621
+ p.appendChild(btn);
622
+ }
623
+ }
624
+
625
+ // ── Load article + model ──
626
+ async function loadArticle() {
627
+ const res = await fetch("data.txt");
628
+ articleSource = await res.text();
629
+ article.innerHTML = markdownToHTML(articleSource);
630
+ }
631
+
632
+ async function loadModel() {
633
+ loadingDetail.textContent = "Downloading model...";
634
+
635
+ generator = await pipeline("text-generation", "onnx-community/LFM2.5-350M-ONNX", {
636
+ dtype: "q4",
637
+ device: "webgpu",
638
+ progress_callback: (progress) => {
639
+ if (progress.status === "progress_total") {
640
+ loadingProgressFill.style.width = `${progress.progress ?? 0}%`;
641
  }
642
+ },
643
+ });
644
 
645
+ loadingOverlay.classList.add("hidden");
646
+ chatInput.disabled = false;
647
+ chatSend.disabled = false;
648
+ chatInput.focus();
649
+ }
650
+
651
+ // ── Summarize a paragraph ──
652
+ async function summarizeParagraph(p) {
653
+ if (!generator || isGenerating) return;
654
+ isGenerating = true;
655
+
656
+ const originalText = p.textContent.trim();
657
+ const originalWordCount = originalText.split(/\s+/).length;
658
+
659
+ const block = document.createElement("div");
660
+ block.className = "summary-block";
661
+
662
+ const label = document.createElement("div");
663
+ label.className = "label";
664
+ label.innerHTML = `<span class="spinner"></span> Summarizing`;
665
+ block.appendChild(label);
666
+
667
+ const output = document.createElement("p");
668
+ output.className = "output";
669
+ block.appendChild(output);
670
+
671
+ p.replaceWith(block);
672
+
673
+ const t0 = performance.now();
674
+ let tFirstToken = null;
675
+ let tokenCount = 0;
676
+
677
+ const streamer = new TextStreamer(generator.tokenizer, {
678
+ skip_prompt: true,
679
+ skip_special_tokens: true,
680
+ callback_function: (text) => {
681
+ tFirstToken ??= performance.now();
682
+ tokenCount++;
683
+ output.textContent += text;
684
+ },
685
+ });
686
 
687
+ try {
688
+ await generator([{ role: "user", content: `Summarize this:\n\n${originalText}` }], {
689
+ max_new_tokens: 512,
690
+ do_sample: false,
691
+ streamer,
692
+ });
693
+ } catch (err) {
694
+ output.textContent = "Error: " + err.message;
695
+ }
696
+
697
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
698
+ const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
699
+ const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
700
+ const summaryWordCount = output.textContent.split(/\s+/).length;
701
+
702
+ label.textContent = "Summary";
703
+
704
+ const stats = document.createElement("div");
705
+ stats.className = "stats";
706
+ stats.innerHTML = `<span>${originalWordCount}</span> words &rarr; <span>${summaryWordCount}</span> words &middot; ${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
707
+ block.appendChild(stats);
708
+
709
+ isGenerating = false;
710
+ }
711
+
712
+ // ── Chat: ask a question about the article ──
713
+ async function askQuestion() {
714
+ const question = chatInput.value.trim();
715
+ if (!question || !generator || isGenerating) return;
716
+
717
+ isGenerating = true;
718
+ chatInput.disabled = true;
719
+ chatSend.disabled = true;
720
+
721
+ // Reset response area
722
+ chatQuestion.textContent = question;
723
+ chatText.textContent = "";
724
+ chatStats.textContent = "";
725
+ chatLabel.innerHTML = `<span class="spinner"></span> Thinking`;
726
+ chatResponse.classList.add("visible");
727
+ chatInput.value = "";
728
+
729
+ const t0 = performance.now();
730
+ let tFirstToken = null;
731
+ let tokenCount = 0;
732
+
733
+ const streamer = new TextStreamer(generator.tokenizer, {
734
+ skip_prompt: true,
735
+ skip_special_tokens: true,
736
+ callback_function: (text) => {
737
+ tFirstToken ??= performance.now();
738
+ tokenCount++;
739
+ chatText.textContent += text;
740
+ },
741
  });
 
 
 
742
 
743
+ try {
744
+ await generator(
745
+ [
746
+ {
747
+ role: "system",
748
+ 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}`,
749
+ },
750
+ { role: "user", content: question },
751
+ ],
752
+ { max_new_tokens: 512, do_sample: false, streamer },
753
+ );
754
+ } catch (err) {
755
+ chatText.textContent = "Error: " + err.message;
756
+ }
757
+
758
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
759
+ const decodeTime = (performance.now() - (tFirstToken ?? t0)) / 1000;
760
+ const tokPerSec = tokenCount > 1 ? ((tokenCount - 1) / decodeTime).toFixed(1) : "\u2014";
761
+
762
+ chatLabel.textContent = "Answer";
763
+ chatStats.innerHTML = `${elapsed}s &middot; <span>${tokPerSec}</span> tok/s`;
764
+
765
+ isGenerating = false;
766
+ chatInput.disabled = false;
767
+ chatSend.disabled = false;
768
+ chatInput.focus();
769
+ }
770
+
771
+ chatSend.onclick = askQuestion;
772
+ chatInput.addEventListener("keydown", (e) => {
773
+ if (e.key === "Enter" && !e.shiftKey) {
774
+ e.preventDefault();
775
+ askQuestion();
776
+ }
777
+ });
778
+ chatClose.onclick = () => {
779
+ if (!isGenerating) chatResponse.classList.remove("visible");
780
+ };
781
+
782
+ // ── Init ──
783
+ await loadArticle();
784
+ attachSummarizeButtons();
785
+ loadModel();
786
  </script>
787
+ </body>
 
788
  </html>