File size: 41,388 Bytes
f341754
024efc3
 
 
f341754
 
 
 
 
 
024efc3
 
 
 
 
 
 
 
 
 
f341754
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
 
 
 
024efc3
 
 
 
f341754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024efc3
f341754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024efc3
f341754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
024efc3
f341754
024efc3
 
 
fcdba1d
024efc3
 
 
 
f341754
 
024efc3
 
 
 
 
f341754
 
024efc3
 
 
 
 
f341754
024efc3
f341754
 
 
 
024efc3
f341754
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
024efc3
 
 
 
fcdba1d
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
f341754
 
024efc3
 
 
 
 
 
f341754
fcdba1d
f341754
024efc3
 
 
 
fcdba1d
f341754
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
 
024efc3
 
f341754
 
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
024efc3
 
 
 
 
 
 
 
 
f341754
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
024efc3
fcdba1d
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f341754
024efc3
f341754
024efc3
 
 
 
 
 
f341754
 
024efc3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
/* UBC Calendar RAG demo: event-stream renderer.
 *
 * The page is driven entirely by the pipeline's own trace events, which arrive in the order
 * they happen. Every mode below emits the same frames, so there is exactly one rendering
 * path: `handle()` does not know or care where its events came from.
 *
 * A run is drawn as four connected stages (Plan, Search, Rank, Answer): a row of stage cards
 * and one panel per stage. `Renderer` below is a port of web/render.py, which draws the same
 * markup server-side for the Gradio Space, so panels.css styles both builds and stages.js
 * (which keeps track of the open stage) serves both. Keep the two renderers in step.
 *
 * Two delivery modes share this file:
 *
 *   server   FastAPI is present (`uvicorn web.app:app`). Live questions stream over SSE and
 *            cached runs replay from /api/replay. This is the full pipeline, using your keys.
 *   static   No backend at all (the Hugging Face static Space). `runs.js` defines
 *            window.DEMO_RUNS and every run replays straight from memory. No keys exist, so
 *            arbitrary questions are impossible; the ask box filters the cached set instead.
 *
 * SSE is consumed via fetch + a hand-rolled parser rather than EventSource, because
 * EventSource cannot read the body of a non-200 response, and the rate-limit and
 * budget-exhausted messages are precisely the ones a visitor needs to see.
 */
'use strict';

// Set by runs.js, which only the static build emits.
const STATIC = typeof window.DEMO_RUNS !== 'undefined';

// Replay pacing, mirrored from web/replay.py so both modes animate identically.
const FRAME_DELAY_MS = 60;
const STAGE_DELAY_MS = 250;

const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
  (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

const el = {
  form: $('ask-form'), question: $('question'), submit: $('submit-btn'),
  mode: $('mode'), route: $('route'), budget: $('budget'), notice: $('notice'),
  examples: $('example-chips'), noMatch: $('no-match'),
  bar: $('stage-bar'), plan: $('panel-plan'), search: $('panel-search'), rank: $('panel-rank'),
  head: $('answer-head'), answer: $('answer-md'), side: $('answer-side'), nav: $('answer-nav'),
  statusDot: $('status-dot'), statusText: $('status-text'),
};

let running = false;
let abort = null;
let site = { chunks: null, model: '' };   // filled in from /api/health or runs.js

// ---------------------------------------------------------------- the stage renderer
const STAGES = ['plan', 'search', 'rank', 'answer'];
const NAMES = { plan: 'Plan', search: 'Search', rank: 'Rank', answer: 'Answer' };
const BLURBS = {
  plan: 'The router decides how to search',
  search: 'Vector and keyword search',
  rank: 'Fuse, rerank, keep the best',
  answer: 'Claude writes it up',
};
// Chips worth surfacing on a result card, after the edition and cohort chips.
const CHIP_FIELDS = ['page_type', 'faculty', 'program', 'subject_code'];
const PATHS = {
  plan: '<path d="M12 20v-7"/><path d="M12 13 6.5 7.5"/><path d="m12 13 5.5-5.5"/><path d="M6 11V7h4"/><path d="M18 11V7h-4"/>',
  search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/>',
  rank: '<path d="M4 5h16l-6 7v6l-4 2v-8z"/>',
  answer: '<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-7l-4 3.5V16H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/><path d="M8 9.5h8M8 12.5h5"/>',
  check: '<path d="m5 12.5 4.2 4.2L19 7"/>',
  left: '<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>',
  right: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
};
const icon = (name, cls = '') => `<svg class="st-i ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" `
  + `stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${PATHS[name]}</svg>`;
const fmt = (value, places = 3) => Number(value).toFixed(places);
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
const badge = (part) => `<span class="part-badge p${part + 1}" title="Part ${part + 1}">${part + 1}</span>`;

function firstLine(markdown) {
  for (const line of String(markdown).split('\n')) {
    const text = line.replace(/[#>*_`]+/g, '').trim();
    if (text) return text;
  }
  return '';
}

class Renderer {
  constructor(question, chunks = null, model = '') {
    this.question = question;
    this.chunks = chunks;
    this.model = model;
    this.runId = Math.random().toString(16).slice(2, 10);
    this.started = false;
    this.state = Object.fromEntries(STAGES.map((s) => [s, 'idle']));
    this.status = '';
    this.route = '';
    this.forced = false;
    this.merging = false;
    this.parts = [];
    this.calls = {};
    this.hops = [];
    this.merge = null;
    this.docs = new Map();
    this.order = [];
    this.notes = Object.fromEntries(STAGES.map((s) => [s, []]));
    this.flow = null;
    this.answerText = '';
    this.error = '';
  }

  // ------------------------------------------------------------ ingest
  feed(ev) {
    const handler = this[`on_${ev.kind}`];
    if (handler) handler.call(this, ev);
  }

  partNote(i) {
    return this.parts.length > 1 ? ` for part ${i + 1} of ${this.parts.length}` : '';
  }

  current() {
    const active = STAGES.find((s) => this.state[s] === 'active');
    if (active) return active;
    const reached = STAGES.filter((s) => this.state[s] !== 'idle');
    return reached.length ? reached[reached.length - 1] : 'plan';
  }

  planDone() {
    if (this.state.plan !== 'done') {
      this.state.plan = 'done';
      this.flow = ['plan', 0];
    }
  }

  searchDone() {
    const i = this.hops.length - 1;
    const more = this.parts.length > 1 && i < this.parts.length - 1;
    this.state.search = more ? 'waiting' : 'done';
    this.flow = ['search', i];
  }

  on_step(ev) {
    const title = String(ev.title || '');
    if (title.startsWith('PIPELINE - answer')) {
      this.started = true;
      this.state.plan = 'active';
      this.status = 'Reading your question';
    } else if (title.startsWith('ROUTE')) {
      this.state.plan = 'active';
      this.status = 'Deciding whether this needs one search or several';
    } else if (title.startsWith('AGENT - decompose')) {
      if (!this.route) { this.route = 'complex'; this.forced = true; }
      this.state.plan = 'active';
      this.status = 'Splitting your question into parts';
    } else if (title.startsWith('RETRIEVE - simple route')) {
      if (!this.route) { this.route = 'simple'; this.forced = true; }
      this.planDone();
    } else if (title.startsWith('AGENT - merge')) {
      this.merging = true;
      this.state.rank = 'active';
      this.status = 'Combining what the searches found';
    } else if (title.startsWith('GENERATE') || title.startsWith('AGENT - synthesize')) {
      ['plan', 'search', 'rank'].forEach((s) => { this.state[s] = 'done'; });
      this.merging = false;
      this.state.answer = 'active';
      this.status = 'Writing the answer';
      this.flow = ['rank', -1];
    }
  }

  on_route(ev) {
    this.route = String(ev.decision || 'simple');
    if (this.route !== 'complex') this.planDone();
  }

  on_subquestions(ev) {
    this.parts = (ev.items || []).map(String);
    this.planDone();
  }

  on_llm_call(ev) {
    this.calls[String(ev.purpose || 'complete')] = ev;
  }

  on_detail(ev) {
    const label = String(ev.label || '');
    const value = String(ev.value ?? '');
    if (label === 'decision' || label.startsWith('sub-question')) return;
    this.notes[this.current()].push(value ? `${label}: ${value}` : label);
  }

  on_retrieval_start(ev) {
    const i = this.hops.length;
    this.hops.push({
      query: String(ev.query || ''), mode: String(ev.mode || ''),
      dense: [], bm25: [], rrf: [], shortlist: [], finals: [],
    });
    this.planDone();
    if (this.state.rank === 'active') this.state.rank = 'waiting';
    this.state.search = 'active';
    this.status = `Searching the calendar${this.partNote(i)}`;
    if (i > 0) this.flow = ['plan', i];
  }

  on_candidates(ev) {
    const stage = String(ev.stage || '');
    if (!this.hops.length || !['dense', 'bm25', 'rrf'].includes(stage)) return;
    this.hops[this.hops.length - 1][stage] = ev.items || [];
    if (stage === 'rrf') {
      this.searchDone();
      this.state.rank = 'active';
      this.status = `Ranking the results${this.partNote(this.hops.length - 1)}`;
    }
  }

  on_shortlist(ev) {
    if (!this.hops.length) return;
    const ids = ev.ids || [];
    this.hops[this.hops.length - 1].shortlist = ids;
    this.state.rank = 'active';
    this.status = `Reranking the top ${ids.length}${this.partNote(this.hops.length - 1)}`;
  }

  on_retrieval_final(ev) {
    const items = ev.items || [];
    items.forEach((doc) => this.docs.set(doc.id, doc));
    if (!this.hops.length) return;
    this.hops[this.hops.length - 1].finals = items;
    if (this.state.search === 'active') this.searchDone();
    if (this.route === 'complex') {
      this.state.rank = 'waiting';
    } else {
      this.state.rank = 'done';
      this.order = items.map((doc) => doc.id);
    }
  }

  on_merge(ev) {
    this.merge = ev;
    this.order = ev.order || [];
    this.state.rank = 'done';
  }

  settle() {
    if (this.answerText && !this.error) {
      STAGES.forEach((s) => { this.state[s] = 'done'; });
    } else if (this.started) {
      // The run ended early: nothing still to come is "waiting" any more.
      STAGES.forEach((s) => { if (this.state[s] !== 'done') this.state[s] = 'stopped'; });
    }
    this.status = '';
    this.merging = false;
  }

  // ------------------------------------------------------------ render: all outputs
  panels(isRunning = false) {
    if (!isRunning) this.settle();
    const out = {
      bar: this.barHtml(isRunning), plan: this.planHtml(), search: this.searchHtml(),
      rank: this.rankHtml(), head: this.answerHeadHtml(isRunning), answer: this.answerText.trim(),
      side: this.answerSideHtml(), nav: navHtml('answer'),
    };
    if (this.error) out.answer = '';
    this.flow = null;
    return out;
  }

  // ------------------------------------------------------------ render: the bar
  barHtml(isRunning) {
    let runState = this.answerText ? 'done' : 'idle';
    if (this.error) runState = 'error';
    else if (isRunning) runState = 'running';
    const cards = [];
    STAGES.forEach((stage, i) => {
      cards.push(this.card(stage));
      if (i < STAGES.length - 1) cards.push(this.arrow(stage));
    });
    return '<div class="stage-top"><h2 class="stage-title">How this answer was found</h2>'
      + '<p class="stage-hint"><span class="hint-touch">Tap a stage to look inside</span>'
      + '<span class="hint-mouse">Click a stage to look inside, or use the arrow keys</span></p></div>'
      + `<div class="stage-bar" role="tablist" aria-label="Pipeline stages" data-run="${this.runId}" `
      + `data-state="${runState}">${cards.join('')}</div>`;
  }

  card(stage) {
    const selected = stage === 'answer';
    return `<button type="button" class="stage-card is-${this.state[stage]}" role="tab" `
      + `id="stage-tab-${stage}" data-stage="${stage}" aria-controls="panel-${stage}" `
      + `aria-selected="${selected}" tabindex="${selected ? 0 : -1}">`
      + `<span class="stage-icon">${icon(stage)}</span>`
      + `<span class="stage-label">${NAMES[stage]}</span>`
      + `<span class="stage-count">${icon('check', 'stage-tick')}${esc(this.count(stage))}</span>`
      + `<span class="stage-blurb">${BLURBS[stage]}</span>`
      + `<span class="stage-mini mini-${stage}" aria-hidden="true">${this.mini(stage)}</span>`
      + '<span class="stage-unseen" aria-hidden="true"></span></button>';
  }

  found() {
    return this.hops.reduce((n, hop) => n + hop.dense.length + hop.bm25.length, 0);
  }

  kept() {
    return this.order.length ? this.order.length : this.hops.reduce((n, hop) => n + hop.finals.length, 0);
  }

  count(stage) {
    const state = this.state[stage];
    if (!this.started) return '';
    if (state === 'idle') return 'waiting';
    if (state === 'stopped') return 'stopped';
    const many = this.parts.length > 1;
    const part = many ? `part ${this.hops.length} of ${this.parts.length}…` : '';
    if (stage === 'plan') {
      if (state === 'active') return this.route === 'complex' ? 'splitting…' : 'thinking…';
      return many ? plural(this.parts.length, 'part') : '1 search';
    }
    if (stage === 'search') {
      if (state === 'active') return part || 'searching…';
      return state === 'waiting' ? `${this.found()} so far` : `${this.found()} found`;
    }
    if (stage === 'rank') {
      if (state === 'active') return this.merging ? 'merging…' : (part || 'ranking…');
      return state === 'waiting' ? `${this.kept()} kept so far` : `${this.kept()} kept`;
    }
    return state === 'active' ? 'writing…' : 'ready';
  }

  mini(stage) {
    if (stage === 'plan') {
      if (this.route === 'complex' && this.parts.length) return this.parts.map((_, k) => `<i class="p${k + 1}"></i>`).join('');
      if (this.route === 'simple') return '<i class="one"></i>';
      return '<i class="wait"></i>';
    }
    if (stage === 'search') {
      const n = Math.max(this.parts.length, 1);
      const dense = Math.min(this.hops.filter((hop) => hop.dense.length).length / n, 1);
      const bm25 = Math.min(this.hops.filter((hop) => hop.bm25.length).length / n, 1);
      return `<i class="md" style="--v:${dense.toFixed(2)}"></i><i class="mk" style="--v:${bm25.toFixed(2)}"></i>`;
    }
    if (stage === 'rank') {
      const found = this.found();
      const fused = this.hops.reduce((n, hop) => n + hop.rrf.length, 0);
      const short = this.hops.reduce((n, hop) => n + hop.shortlist.length, 0);
      const kept = this.hops.some((hop) => hop.finals.length) ? this.kept() : 0;
      return [[found, 1, ''], [fused, 0.87, ''], [short, 0.5, ''], [kept, 0.04, ' k']].map(([value, nominal, keep]) => {
        const width = found && value ? value / found : nominal;
        return `<i class="${value ? 'on' : ''}${keep}" style="--w:${width.toFixed(3)}"></i>`;
      }).join('');
    }
    return this.answerText && !this.error ? esc(firstLine(this.answerText)) : '';
  }

  arrow(stage) {
    const filled = {
      plan: this.state.plan === 'done',
      search: this.hops.some((hop) => hop.rrf.length || hop.finals.length),
      rank: this.state.rank === 'done',
    }[stage];
    let classes = `stage-arrow${filled ? ' is-filled' : ''}`;
    if (this.flow && this.flow[0] === stage) {
      classes += ' is-flowing';
      if (this.flow[1] >= 0 && this.parts.length > 1) classes += ` part${this.flow[1] + 1}`;
    }
    return `<span class="${classes}" aria-hidden="true">${icon('right')}</span>`;
  }

  // ------------------------------------------------------------ render: the panels
  planHtml() {
    const what = 'A quick model call reads the question and decides how much searching it needs. '
      + 'Simple questions get one search. Comparisons and prerequisite chains are split '
      + 'into parts, and each part is searched on its own.';
    if (!this.started) return panelHtml('plan', what, waitHtml("Ask a question and the router's decision shows up here."));
    const body = [`<div class="sp-quote">${esc(this.question)}</div>`];
    if (this.state.plan === 'active') body.push(statusHtml(this.status));
    if (this.route) {
      const isComplex = this.route === 'complex';
      let why = isComplex
        ? 'It needs more than one calendar page, so it is split into parts and each part gets its own search.'
        : 'One calendar page should answer it, so it gets a single search.';
      if (this.forced) {
        why = `Search settings set the route to always ${isComplex ? 'multi-step' : 'single-shot'}, so the router was skipped.`;
      }
      body.push(`<div class="sp-decision"><span class="sp-pill">${isComplex ? 'Complex' : 'Simple'}</span><span>${why}</span></div>`);
    }
    if (this.calls.route) body.push(promptHtml(this.calls.route, 'Router prompt and reply'));
    if (this.parts.length) {
      body.push(`<ol class="sp-parts">${this.parts.map((part, k) => `<li>${badge(k)}<span>${esc(part)}</span></li>`).join('')}</ol>`);
    }
    if (this.calls.decompose) body.push(promptHtml(this.calls.decompose, 'Splitting prompt and reply'));
    body.push(this.notesHtml('plan'));
    return panelHtml('plan', what, body.join(''));
  }

  laneHead(i) {
    const query = i < this.parts.length ? this.parts[i] : this.hops[i].query;
    return `<div class="sp-lane-head">${badge(i)}<span>${esc(query)}</span></div>`;
  }

  searchHtml() {
    const corpus = this.chunks ? `all ${Number(this.chunks).toLocaleString('en-US')} calendar excerpts` : 'every calendar excerpt';
    const what = `Two searches run side by side. Vector search compares the meaning of the question with ${corpus}. `
      + 'Keyword search (BM25) looks for the exact words, which is how a course code like CPSC 210 gets found.';
    if (!this.hops.length) {
      return panelHtml('search', what, waitHtml(this.started ? 'Waiting for Plan.' : 'Ask a question and the two searches show up here.'));
    }
    const many = this.parts.length > 1;
    const lanes = [];
    for (let i = 0; i < Math.max(this.parts.length, this.hops.length); i += 1) {
      const head = many ? this.laneHead(i) : '';
      if (i >= this.hops.length) {
        lanes.push(`<section class="sp-lane">${head}${waitHtml(`Part ${i + 1} is searched after part ${i} has been ranked.`)}</section>`);
      } else {
        lanes.push(`<section class="sp-lane" data-lane="${i}">${head}${searchListsHtml(this.hops[i])}</section>`);
      }
    }
    const status = this.state.search === 'active' ? statusHtml(this.status) : '';
    const first = this.hops[0];
    let tech = '';
    if (first.bm25.length) {
      tech = `Each search returns its top ${first.dense.length}. Pages that both searches found are marked <b>both</b>, `
        + 'and the ones that made the final cut are marked <b>kept</b>.';
    } else if (first.dense.length) {
      tech = `Vector search returns its top ${first.dense.length}, and those are what Rank keeps.`;
    }
    return panelHtml('search', what, status + lanes.join('') + (tech ? `<p class="sp-tech">${tech}</p>` : '') + this.notesHtml('search'));
  }

  rankHtml() {
    const first = this.hops[0] || { mode: '', shortlist: [], finals: [] };
    const kept = first.finals.length || 4;
    const fuse = 'The two lists are fused into one ranking (reciprocal rank fusion), so a page that both searches found rises to the top.';
    let what;
    if (first.mode === 'dense') what = `Vector search only, so there is nothing to fuse: the ${kept} closest excerpts are kept.`;
    else if (first.mode === 'hybrid') what = `${fuse} The best ${kept} are kept. This search mode skips the reranker.`;
    else {
      what = `${fuse} The top ${first.shortlist.length || 50} go to a reranker (Cohere) that reads each excerpt `
        + `against the question, and the best ${kept} are kept.`;
    }
    const many = this.parts.length > 1;
    if (many) what += ' Each part is ranked on its own, then the parts are merged.';
    if (!this.hops.some((hop) => hop.rrf.length || hop.finals.length)) {
      const status = this.state.rank === 'active' ? statusHtml(this.status) : '';
      return panelHtml('rank', what, status + waitHtml(this.started ? 'Waiting for Search.' : 'Ask a question and the ranking shows up here.'));
    }
    const lanes = [];
    for (let i = 0; i < Math.max(this.parts.length, this.hops.length); i += 1) {
      const head = many ? this.laneHead(i) : '';
      const hop = this.hops[i];
      if (!hop || !(hop.rrf.length || hop.finals.length)) {
        lanes.push(`<section class="sp-lane">${head}${waitHtml(`Part ${i + 1} is ranked after it has been searched.`)}</section>`);
        continue;
      }
      let cards = hop.finals.map((doc) => docCardHtml(doc, hop, many ? i : null)).join('');
      if (cards) cards = `<div class="sp-cards">${cards}</div>`;
      lanes.push(`<section class="sp-lane">${head}${funnelHtml(hop)}${cards}${fusedHtml(hop)}</section>`);
    }
    const status = this.state.rank === 'active' ? statusHtml(this.status) : '';
    return panelHtml('rank', what, status + lanes.join('') + (this.merge ? this.mergeHtml() : '') + this.notesHtml('rank'));
  }

  mergeHtml() {
    const perHop = this.merge.per_hop || [];
    const seen = new Set();
    const steps = [];
    const depth = Math.max(0, ...perHop.map((ids) => ids.length));
    for (let rank = 0; rank < depth; rank += 1) {
      perHop.forEach((ids, part) => {
        if (rank >= ids.length) return;
        const id = ids[rank];
        const title = esc((this.docs.get(id) || {}).title || `Excerpt ${id}`);
        if (seen.has(id)) {
          steps.push(`<li class="is-drop">${badge(part)}<span class="sp-strike">${title}</span> (already in)</li>`);
        } else {
          seen.add(id);
          steps.push(`<li>${badge(part)}<span>${title}</span></li>`);
        }
      });
    }
    const dropped = Number(this.merge.dropped || 0);
    return `<section class="sp-merge"><h4>Merging the ${plural(perHop.length, 'part')}</h4>`
      + "<p class=\"sp-tech\">Each part's best excerpt goes in first, then each part's second best, and so on. "
      + `Repeats are dropped. That leaves ${plural(this.order.length, 'excerpt')}, with ${plural(dropped, 'repeat')} dropped.</p>`
      + `<ol>${steps.join('')}</ol></section>`;
  }

  answerHeadHtml(isRunning) {
    const model = String((this.calls.answer || {}).model || this.model || '');
    let who = esc(model) || 'The model';
    if (model.includes('claude')) who = `Claude (${esc(model)})`;
    const what = `${who} writes the answer using only the kept excerpts, and points you to the official calendar pages.`;
    let body;
    if (this.error) body = `<div class="notice">${esc(this.error)}</div>`;
    else if (this.answerText) body = `<p class="sp-answer-meta"><span class="sp-badge">${this.route === 'complex' ? 'multi-step' : 'single-shot'}</span></p>`;
    else if (isRunning || this.started) body = statusHtml(this.status || 'Reading your question');
    else body = waitHtml('Your answer will show up here, with links to the calendar pages it came from.');
    return '<div class="stage-panel" role="tabpanel" aria-labelledby="stage-tab-answer">'
      + `${headHtml('answer', what)}<div class="sp-body">${body}</div></div>`;
  }

  sources() {
    const perHop = ((this.merge || {}).per_hop || []).map((ids) => new Set(ids));
    const many = this.parts.length > 1;
    const pages = new Map();
    for (const id of (this.order.length ? this.order : [...this.docs.keys()])) {
      const doc = this.docs.get(id);
      if (!doc || !doc.url) continue;
      const parts = many ? perHop.map((ids, k) => (ids.has(id) ? k : -1)).filter((k) => k >= 0) : [];
      if (pages.has(doc.url)) parts.forEach((k) => pages.get(doc.url).parts.add(k));
      else pages.set(doc.url, { title: String(doc.title || doc.url), parts: new Set(parts) });
    }
    return [...pages].map(([url, page]) => ({ url, title: page.title, parts: [...page.parts].sort() }));
  }

  answerSideHtml() {
    if (this.error || !this.answerText) return '';
    const sources = this.sources();
    let side = '';
    if (sources.length) {
      const items = sources.map((s) => `<li><span class="sp-badges">${s.parts.map(badge).join('')}</span>`
        + `<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.title)}</a></li>`).join('');
      side += `<section class="sp-sources"><h4>Based on ${plural(sources.length, 'calendar page')}</h4><ul>${items}</ul></section>`;
    }
    if (this.calls.answer) side += promptHtml(this.calls.answer, 'Answer prompt and reply');
    return `<div class="sp-answer-side">${side}</div>`;
  }

  notesHtml(stage) {
    const lines = this.notes[stage];
    if (!lines.length) return '';
    return `<details class="prompt sp-notes"><summary>Raw trace, ${plural(lines.length, 'line')}</summary>`
      + `<div class="prompt-body"><pre>${esc(lines.join('\n'))}</pre></div></details>`;
  }
}

// ---------------------------------------------------------------- panel pieces
function panelHtml(stage, what, body) {
  return `<div class="stage-panel" role="tabpanel" aria-labelledby="stage-tab-${stage}">`
    + `${headHtml(stage, what)}<div class="sp-body">${body}</div>${navHtml(stage)}</div>`;
}

function headHtml(stage, what) {
  return `<div class="sp-head"><span class="sp-icon">${icon(stage)}</span><div>`
    + `<p class="sp-eyebrow">Stage ${STAGES.indexOf(stage) + 1} of 4</p>`
    + `<h3 class="sp-title">${NAMES[stage]}</h3></div></div><p class="sp-what">${what}</p>`;
}

function navHtml(stage) {
  const i = STAGES.indexOf(stage);
  const back = i ? `<button type="button" class="sp-btn" data-go="${STAGES[i - 1]}">${icon('left')}${NAMES[STAGES[i - 1]]}</button>` : '';
  const next = i < STAGES.length - 1
    ? `<button type="button" class="sp-btn sp-next" data-go="${STAGES[i + 1]}">Next: ${NAMES[STAGES[i + 1]]}${icon('right')}</button>`
    : '<button type="button" class="sp-btn sp-next" data-go="plan">See how it was found: start at Plan</button>';
  return `<div class="sp-nav">${back}${next}</div>`;
}

const statusHtml = (text) => `<div class="sp-status"><span class="sp-dot" aria-hidden="true"></span><span>${esc(text)}</span></div>`;
const waitHtml = (text) => `<div class="sp-wait">${text}</div>`;

function promptHtml(call, label) {
  return '<details class="prompt">'
    + `<summary>${esc(label)}<span class="prompt-meta">${esc(call.model || '')} · ${call.ms || 0} ms</span></summary>`
    + `<div class="prompt-body"><h4>System</h4><pre>${esc(call.system)}</pre>`
    + `<h4>User</h4><pre>${esc(call.user)}</pre><h4>Reply</h4><pre>${esc(call.reply)}</pre></div></details>`;
}

function searchListsHtml(hop) {
  if (!hop.dense.length) return waitHtml('Searching…');
  const vectorOnly = hop.mode === 'dense';
  let both = new Set();
  if (hop.bm25.length) {
    const denseIds = new Set(hop.dense.map((c) => c.id));
    both = new Set(hop.bm25.map((c) => c.id).filter((id) => denseIds.has(id)));
  }
  const kept = new Set(hop.finals.map((doc) => doc.id));
  const lists = [candListHtml('dense', 'Vector search', hop.dense, both, kept, 4)];
  if (vectorOnly) {
    lists.push('<div class="sp-list bm25"><div class="sp-list-head">Keyword search</div><div class="sp-wait">Off in this search mode.</div></div>');
  } else {
    lists.push(candListHtml('bm25', 'Keyword search', hop.bm25, both, kept, 2));
  }
  const keyword = hop.bm25.length ? `Keyword ${hop.bm25.length}` : 'Keyword off';
  const toggle = '<div class="sp-toggle" role="group" aria-label="Which list to show">'
    + `<button type="button" data-pick="dense" aria-pressed="true">Vector ${hop.dense.length}</button>`
    + `<button type="button" data-pick="bm25" aria-pressed="false">${keyword}</button></div>`;
  return `${toggle}<div class="sp-lists">${lists.join('')}</div>`;
}

function candRowHtml(cand, both, kept, places, cut = false) {
  let flags = '';
  if (both.has(cand.id)) flags += '<span class="sp-flag both" title="Found by both searches">both</span>';
  if (kept.has(cand.id)) flags += '<span class="sp-flag kept" title="Kept for the answer">kept</span>';
  if (cut) flags += '<span class="sp-flag cut" title="Not sent to the reranker">cut</span>';
  const title = esc(cand.title || '');
  const classes = `sp-row${kept.has(cand.id) ? ' is-kept' : ''}${cut ? ' is-cut' : ''}`;
  return `<li class="${classes}"><span class="sp-rid">${cand.id}</span>`
    + `<span class="sp-rtitle" title="${title}">${title}</span><span class="sp-flags">${flags}</span>`
    + `<span class="sp-rscore">${fmt(cand.score, places)}</span></li>`;
}

function candListHtml(kind, name, items, both, kept, places) {
  const rows = items.map((c) => candRowHtml(c, both, kept, places));
  const more = rows.length > 5
    ? `<details class="sp-more"><summary>Show all ${rows.length}</summary><ol class="sp-rows">${rows.slice(5).join('')}</ol></details>`
    : '';
  return `<div class="sp-list ${kind}"><div class="sp-list-head">${name}<span class="sp-n">top ${items.length}</span></div>`
    + `<ol class="sp-rows">${rows.slice(0, 5).join('')}</ol>${more}</div>`;
}

function funnelHtml(hop) {
  const found = hop.dense.length + hop.bm25.length;
  if (!found) return '';
  const split = `<i class="d" style="flex-grow:${hop.dense.length}"></i><i class="k" style="flex-grow:${hop.bm25.length}"></i>`;
  const rows = [['Candidates', found, 'split', split]];
  if (hop.rrf.length) rows.push(['After fusing', hop.rrf.length, '', '']);
  if (hop.shortlist.length) rows.push(['Sent to reranker', hop.shortlist.length, '', '']);
  if (hop.finals.length) rows.push(['Kept', hop.finals.length, 'keep', '']);
  return '<div class="sp-funnel">' + rows.map(([label, value, cls, inner]) =>
    `<div class="fn-row"><span class="fn-label">${label}</span><span class="fn-track">`
    + `<span class="fn-bar ${cls}" style="--w:${(value / found).toFixed(4)}">${inner}</span></span>`
    + `<span class="fn-val">${value}</span></div>`).join('') + '</div>';
}

function fusedHtml(hop) {
  if (!hop.rrf.length) return '';
  const kept = new Set(hop.finals.map((doc) => doc.id));
  const short = new Set(hop.shortlist);
  const rows = hop.rrf.map((c) => candRowHtml(c, new Set(), kept, 4, short.size > 0 && !short.has(c.id))).join('');
  return `<details class="prompt sp-fused"><summary>The fused ranking, all ${hop.rrf.length}`
    + '<span class="prompt-meta">RRF</span></summary>'
    + `<div class="sp-list fused"><ol class="sp-rows sp-scroll">${rows}</ol></div></details>`;
}

function docCardHtml(doc, hop, part) {
  const chips = [];
  if (doc.edition_year) chips.push(`<span class="tag edition">${esc(doc.edition_year)}</span>`);
  if (doc.cohort_qualifier) chips.push('<span class="tag cohort">cohort-specific</span>');
  CHIP_FIELDS.forEach((field) => { if (doc[field]) chips.push(`<span class="tag">${esc(doc[field])}</span>`); });
  const foundBy = [];
  [['dense', 'vector', 'dv'], ['bm25', 'keyword', 'kw']].forEach(([kind, label, css]) => {
    if (!hop[kind].length) return;
    const rank = hop[kind].findIndex((c) => c.id === doc.id) + 1;
    foundBy.push(rank ? `<span class="${css}">${label} #${rank}</span>` : `not in the ${label} top ${hop[kind].length}`);
  });
  const section = doc.section ? `<p class="doc-section">${esc(doc.section)}</p>` : '';
  const chipRow = chips.length ? `<div class="doc-chips">${chips.join('')}</div>` : '';
  const provenance = foundBy.length ? `<p class="doc-prov">Found by ${foundBy.join(' · ')}</p>` : '';
  const link = doc.url
    ? `<div class="doc-actions"><a href="${esc(doc.url)}" target="_blank" rel="noopener noreferrer">Official page ↗</a></div>`
    : '';
  return '<article class="doc">'
    + `<div class="doc-head">${part !== null ? badge(part) : ''}<span class="doc-title">${esc(doc.title)}</span>`
    + `<span class="doc-score" title="Relevance score">id ${doc.id} · ${fmt(doc.score)}</span></div>`
    + `${section}${chipRow}${provenance}${link}`
    + `<details class="excerpt"><summary>Excerpt</summary><div class="doc-text">${esc(doc.text)}</div></details></article>`;
}

// ---------------------------------------------------------------- painting
let renderer = new Renderer('');
let paintQueued = false;

/* Repaint every output from the renderer. Unchanged outputs are left alone, so a stage
 * someone is reading (an open "Show all" list, a scrolled excerpt) is not reset by an update
 * to a different stage. */
const painted = {};
function paint(isRunning) {
  const out = renderer.panels(isRunning);
  const targets = { bar: el.bar, plan: el.plan, search: el.search, rank: el.rank, head: el.head, side: el.side, nav: el.nav };
  Object.entries(targets).forEach(([key, node]) => {
    if (painted[key] !== out[key]) { node.innerHTML = out[key]; painted[key] = out[key]; }
  });
  if (painted.answer !== out.answer) { el.answer.textContent = out.answer; painted.answer = out.answer; }
}

// Events can arrive dozens at a time; paint at most once a frame while a run is going.
function schedulePaint() {
  if (paintQueued) return;
  paintQueued = true;
  requestAnimationFrame(() => { paintQueued = false; paint(running); });
}

// ---------------------------------------------------------------- SSE plumbing
async function streamSSE(url, onEvent) {
  abort = new AbortController();
  const response = await fetch(url, { signal: abort.signal, headers: { Accept: 'text/event-stream' } });

  if (!response.ok) {
    let body = {};
    try { body = await response.json(); } catch (_) { /* non-JSON error page */ }
    const err = new Error(body.error || `Request failed (${response.status})`);
    err.status = response.status;
    err.budgetExhausted = !!body.budget_exhausted;
    throw err;
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    let split;
    while ((split = buffer.indexOf('\n\n')) !== -1) {
      const block = buffer.slice(0, split);
      buffer = buffer.slice(split + 2);
      if (!block.trim() || block.startsWith(':')) continue;   // heartbeat comment

      let name = 'message', data = '';
      for (const line of block.split('\n')) {
        if (line.startsWith('event:')) name = line.slice(6).trim();
        else if (line.startsWith('data:')) data += line.slice(5).trim();
      }
      if (data) {
        try { onEvent(name, JSON.parse(data)); }
        catch (_) { /* a malformed frame must not kill the stream */ }
      }
    }
  }
}

// ---------------------------------------------------------------- dispatch
function handle(name, data) {
  switch (name) {
    case 'accepted':
      renderer = new Renderer(data.question, site.chunks, site.model);
      el.question.value = data.question;
      el.notice.hidden = true;
      // In static mode the standing note above the chips already says everything is a
      // recording, so flashing a banner on every click would just be noise.
      if (data.replay && !STATIC) {
        note('Playing back a saved run. No API calls, nothing metered.');
      }
      break;

    case 'answer':
      renderer.answerText = String(data.text || '');
      renderer.route = data.route || renderer.route;
      break;

    case 'done':
      if (!data.replay && data.api_calls) {
        const c = data.api_calls;
        note(`Done in ${(data.ms / 1000).toFixed(1)}s using ${c.llm} model call${c.llm === 1 ? '' : 's'}, `
             + `${c.embed} embedding${c.embed === 1 ? '' : 's'}, ${c.rerank} rerank${c.rerank === 1 ? '' : 's'}.`);
      }
      refreshUsage();
      break;

    case 'error':
      renderer.error = data.message;
      break;

    default:
      // Every trace event the pipeline emits: step, route, candidates, and the rest.
      renderer.feed({ ...data, kind: data.kind || name });
  }
  schedulePaint();
}

function note(message) {
  el.notice.textContent = message;
  el.notice.hidden = false;
}

// ---------------------------------------------------------------- static replay
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function staticRun(slug) {
  return (window.DEMO_RUNS.runs || []).find((r) => r.slug === slug);
}

/* Walk a cached run's frames with the same pacing the server uses, so the stage cards animate
 * rather than appearing all at once. Emits the identical event shapes `handle()` expects. */
async function replayLocal(slug) {
  const runData = staticRun(slug);
  if (!runData) {
    handle('error', { message: 'That saved run is not available.' });
    return;
  }
  handle('accepted', {
    question: runData.question,
    mode: runData.mode || 'hybrid_rerank',
    route: runData.forced_route || 'auto',
    replay: true,
  });
  for (const event of runData.events) {
    await sleep(event.kind === 'step' ? STAGE_DELAY_MS : FRAME_DELAY_MS);
    handle(event.kind, event);
  }
  handle('answer', { text: runData.answer, route: runData.route });
  handle('done', {
    route: runData.route,
    excerpts: runData.excerpts || 0,
    ms: runData.ms || 0,
    replay: true,
  });
}

// ---------------------------------------------------------------- driving
async function run(target, question) {
  if (running) return;
  running = true;
  el.submit.disabled = true;
  el.submit.textContent = 'Working…';
  renderer = new Renderer(question, site.chunks, site.model);
  schedulePaint();

  try {
    if (target.slug) await replayLocal(target.slug);
    else await streamSSE(target.url, handle);
  } catch (err) {
    if (err.name !== 'AbortError') {
      renderer.error = err.budgetExhausted
        ? err.message + ' Pick one of the saved runs above to see the whole thing work.'
        : err.message;
    }
  } finally {
    running = false;
    el.submit.disabled = false;
    el.submit.textContent = STATIC ? 'Filter' : 'Ask';
    paint(false);
    refreshUsage();
  }
}

/* In static mode there is no backend to ask, so the input filters the cached questions
 * instead of pretending to run one. Filtering as you type is honest about what the page can
 * do, and still lets someone hunt for the question they had in mind. */
function filterExamples(term) {
  const needle = term.trim().toLowerCase();
  let visible = 0;
  el.examples.querySelectorAll('.chip').forEach((chip) => {
    const match = !needle || chip.dataset.question.toLowerCase().includes(needle)
      || (chip.dataset.category || '').toLowerCase().includes(needle);
    chip.hidden = !match;
    if (match) visible += 1;
  });
  el.noMatch.hidden = visible > 0;
  el.examples.dispatchEvent(new Event('scroll'));   // let the row's arrows catch up
}

el.form.addEventListener('submit', (ev) => {
  ev.preventDefault();
  const question = el.question.value.trim();
  if (!question) return;
  if (STATIC) {
    filterExamples(question);
    return;
  }
  const params = new URLSearchParams({ q: question, mode: el.mode.value, route: el.route.value });
  run({ url: `/api/ask/stream?${params}` }, question);
});

if (STATIC) el.question.addEventListener('input', () => filterExamples(el.question.value));

// ---------------------------------------------------------------- bootstrap
function renderExamples(examples) {
  // One row that scrolls sideways, so a long question is cut short on its chip; the full
  // text is in the tooltip and fills the box when the run starts.
  el.examples.innerHTML = examples.map((ex) =>
    `<button class="chip" type="button" data-slug="${esc(ex.slug)}"
             data-question="${esc(ex.question)}" data-category="${esc(ex.category || '')}"
             title="${esc(ex.question)}">${ex.category ? `<span class="chip-cat">${esc(ex.category)}</span>` : ''}<span class="chip-q">${esc(ex.question)}</span></button>`).join('');

  el.examples.querySelectorAll('.chip').forEach((chip) => {
    chip.addEventListener('click', () => {
      const question = chip.dataset.question;
      run(STATIC ? { slug: chip.dataset.slug }
                 : { url: `/api/replay/${encodeURIComponent(chip.dataset.slug)}` }, question);
    });
  });
}

function renderUsage(usage) {
  if (!usage) return;
  el.budget.textContent = usage.budget_exhausted
    ? 'out of model calls for today, saved runs still work'
    : `${usage.llm_calls_remaining}/${usage.llm_calls_limit} model calls left today · ` +
      `${usage.per_ip_hour}/hour per visitor`;
}

async function refreshUsage() {
  if (STATIC) return;   // nothing is metered when nothing is spent
  try {
    const health = await (await fetch('/api/health')).json();
    renderUsage(health.usage);
    site = { chunks: health.chunks, model: health.model };
    const ok = health.status === 'ok';
    el.statusDot.className = 'dot ' + (ok ? (health.usage.budget_exhausted ? 'warn' : 'ok') : 'err');
    el.statusText.textContent = ok
      ? `${health.chunks.toLocaleString()} chunks · ${health.retrieval_mode} · ${health.model}`
      : 'index unavailable';
  } catch (_) {
    el.statusDot.className = 'dot err';
    el.statusText.textContent = 'offline';
  }
}

function initStatic() {
  const meta = window.DEMO_RUNS;
  document.body.classList.add('static-mode');
  site = { chunks: meta.chunks || null, model: meta.model || '' };

  el.question.placeholder = `Filter ${meta.runs.length} saved runs…`;
  el.submit.textContent = 'Filter';
  el.budget.textContent = 'no API keys, nothing metered';
  el.statusDot.className = 'dot ok';
  el.statusText.textContent =
    `${(meta.chunks || 0).toLocaleString()} chunks · ${meta.retrieval_mode || 'hybrid_rerank'} · ${meta.model || ''}`;

  renderExamples(meta.runs.map((r) => ({
    slug: r.slug, question: r.question, category: r.category, cached: true,
  })));
}

async function initServer() {
  refreshUsage();
  try {
    const data = await (await fetch('/api/examples')).json();
    if (data.examples.length) renderExamples(data.examples);
    else el.examples.closest('.examples-wrap').hidden = true;
  } catch (_) {
    el.examples.closest('.examples-wrap').hidden = true;
  }
}

(function init() {
  if (STATIC) initStatic();
  else initServer();
  renderer = new Renderer('', site.chunks, site.model);
  paint(false);
})();