File size: 59,019 Bytes
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488dd26
51720d8
 
 
488dd26
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c0bd3b
51720d8
 
9c0bd3b
51720d8
 
 
 
 
 
 
 
488dd26
 
51720d8
488dd26
51720d8
 
488dd26
51720d8
 
9c0bd3b
51720d8
 
 
 
 
 
 
 
 
 
2205fd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488dd26
2205fd2
 
 
488dd26
 
 
51720d8
488dd26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
488dd26
51720d8
2205fd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022e34
 
 
 
 
2205fd2
 
 
 
 
 
488dd26
 
 
 
 
 
51720d8
 
488dd26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da9b8a8
 
488dd26
51720d8
 
488dd26
8cda180
da9b8a8
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
da9b8a8
 
 
51720d8
 
 
c982278
51720d8
c982278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3dfd032
bfa15f4
 
3dfd032
 
 
 
 
 
 
a86d9d6
7cb8bf7
3dfd032
 
 
 
 
 
 
 
 
0bd293b
 
 
 
 
 
3dfd032
0bd293b
a86d9d6
3dfd032
 
0cdbcc1
c982278
 
51720d8
3dfd032
51720d8
 
c982278
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14a9b91
 
51720d8
 
 
 
 
 
 
 
94a3264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08f584d
 
 
 
 
ecee177
 
 
 
 
08f584d
 
ecee177
 
 
 
08f584d
ecee177
 
3f92f69
 
 
 
 
 
 
 
 
 
 
 
 
 
1297cde
 
 
 
 
 
 
 
 
 
 
 
 
 
d8f1e4d
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
 
 
 
 
 
 
 
488dd26
 
 
 
 
 
 
 
 
 
 
 
 
a86d9d6
488dd26
 
51720d8
 
 
838a298
 
 
51720d8
14a9b91
3f92f69
 
 
 
94a3264
 
 
 
 
ecee177
 
 
 
d8f1e4d
 
 
 
488dd26
51720d8
1297cde
 
 
 
 
488dd26
14a9b91
 
 
 
 
 
da9b8a8
 
 
 
 
2205fd2
 
 
 
89111aa
488dd26
c982278
3dfd032
 
 
51720d8
a86d9d6
3dfd032
 
 
 
 
a86d9d6
 
 
 
 
 
3dfd032
 
8cda180
 
 
 
 
3dfd032
8cda180
a86d9d6
3dfd032
 
 
 
51720d8
 
488dd26
51720d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488dd26
 
 
 
9c0bd3b
89111aa
c982278
2205fd2
 
3dfd032
c982278
 
 
 
 
 
 
 
488dd26
c982278
 
 
 
94a3264
488dd26
c982278
 
 
 
 
 
488dd26
c982278
 
 
 
 
488dd26
1297cde
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c982278
14a9b91
 
 
 
 
 
 
2205fd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022e34
2205fd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022e34
2205fd2
1022e34
2205fd2
 
 
 
 
 
 
1022e34
2205fd2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022e34
2205fd2
 
 
 
 
 
 
 
 
 
 
 
89111aa
 
c982278
 
14a9b91
 
 
 
c982278
 
 
14a9b91
c982278
 
 
89111aa
c982278
488dd26
c982278
 
 
 
 
51720d8
 
 
488dd26
c982278
2205fd2
 
89111aa
 
2205fd2
89111aa
640e99d
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
89111aa
94a3264
ecee177
838a298
c982278
3dfd032
14a9b91
d8f1e4d
c982278
9c0bd3b
89111aa
9c0bd3b
 
 
2205fd2
89111aa
51720d8
2205fd2
1297cde
488dd26
9c0bd3b
 
488dd26
 
 
89111aa
488dd26
 
89111aa
9c0bd3b
89111aa
 
 
9c0bd3b
 
 
 
 
14a9b91
 
1022e34
14a9b91
 
9c0bd3b
 
2205fd2
9c0bd3b
2205fd2
 
9c0bd3b
 
 
89111aa
488dd26
89111aa
 
 
 
 
 
 
1022e34
 
 
 
 
 
 
14a9b91
 
 
 
7cb8bf7
 
 
 
 
 
 
 
d3bb78b
7cb8bf7
89111aa
 
 
14a9b91
89111aa
 
9c0bd3b
 
 
 
51720d8
da9b8a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94a3264
 
 
 
 
 
 
 
da9b8a8
 
 
 
 
b6e489c
 
 
102587b
 
b6e489c
 
 
 
16ee6c6
 
 
 
 
 
 
 
 
 
b6e489c
 
 
 
16ee6c6
 
 
 
 
da9b8a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51720d8
 
 
 
 
 
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
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
<!doctype html>
<html lang="en" class="h-full">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Repair Guy</title>

  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'] },
          colors: {
            // steel blue — primary "workshop" blue
            brand: {
              50:'#eff5fb',100:'#dbe8f4',200:'#bcd3e9',300:'#90b6d9',400:'#5e93c2',
              500:'#3d79ad',600:'#2f6090',700:'#284f76',800:'#244362',900:'#223a53'
            },
            // amber — gear / accent pop
            accent: { 400:'#fbbf24',500:'#f59e0b',600:'#d97706' },
            // deep navy — outlines & headings
            navy: '#1e3553'
          },
          boxShadow: { panel: '0 1px 3px rgba(30,53,83,.06), 0 12px 32px rgba(30,53,83,.08)' }
        }
      }
    }
  </script>

  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
  <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>

  <style>
    [x-cloak]{display:none!important}
    html,body{height:100%}
    body{background:linear-gradient(180deg,#eef3f9 0%,#dfe9f3 100%)}
    .scroll-thin::-webkit-scrollbar{width:8px}
    .scroll-thin::-webkit-scrollbar-thumb{background:#b6cbe1;border-radius:9999px}
    .scroll-thin::-webkit-scrollbar-track{background:transparent}

    /* wrench "tightening a bolt" wiggle for the busy indicator */
    @keyframes wrench-turn{0%,100%{transform:rotate(-24deg)}50%{transform:rotate(20deg)}}
    .animate-wrench{animation:wrench-turn 1s ease-in-out infinite;transform-origin:50% 50%}

    /* subtle blueprint cross-hatch behind empty states / the page well */
    .chat-grid{
      background-color:#fff;
      background-image:
        linear-gradient(to right, rgba(61,121,173,.06) 1px, transparent 1px),
        linear-gradient(to bottom, rgba(61,121,173,.06) 1px, transparent 1px);
      background-size:22px 22px;
      background-attachment:local;
    }
  </style>
</head>

<body class="h-full text-slate-800 antialiased">
<div x-data="repairGuy()" x-init="init()" x-cloak class="h-full flex flex-col max-w-[1700px] mx-auto px-4 sm:px-6">

  <!-- Header -->
  <header class="flex items-center justify-between py-4">
    <div class="flex items-center gap-3">
      <img src="/media/repair_guy_logo_sm.png" alt="Repair Guy" class="w-16 h-16 object-contain shrink-0" />
      <div>
        <h1 class="text-lg font-bold tracking-tight text-navy leading-none">Repair Guy</h1>
        <p class="text-[13px] text-brand-700/70 mt-0.5">Offline AI repair manual assistant</p>
      </div>
    </div>
    <button @click="settingsOpen=true" aria-label="Settings" title="Settings"
      class="grid place-items-center w-10 h-10 rounded-full text-brand-500 hover:text-navy hover:bg-brand-100 transition">
      <i data-lucide="settings" class="w-5 h-5"></i>
    </button>
  </header>

  <!-- Main: narrow navigator rail + dominant page viewer -->
  <main class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[minmax(290px,340px)_1fr] gap-5 pb-5">

    <!-- LEFT: navigator rail — manual picker · table of contents · command bar -->
    <section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden">
      <!-- manual picker -->
      <div class="px-4 pt-4 pb-3 border-b border-brand-100 shrink-0">
        <label class="block text-[11px] font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Manual</label>
        <div class="relative">
          <select x-model="manual" @change="newChat()"
            class="w-full appearance-none rounded-xl border border-brand-200 bg-brand-50/50 px-3.5 py-2.5 pr-9 text-sm font-medium text-navy focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition">
            <option value="">Select a manual…</option>
            <template x-for="m in manuals" :key="m.value">
              <option :value="m.value" x-text="m.label"></option>
            </template>
          </select>
          <i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-brand-400"></i>
        </div>
      </div>

      <!-- tabs: the manual's contents · the agent's diagnostic trace -->
      <div class="px-3 pt-3 pb-2 shrink-0 flex items-center gap-2 border-b border-brand-100">
        <div class="flex items-center gap-0.5 rounded-lg bg-brand-50 p-0.5 ring-1 ring-brand-100">
          <button @click="tab='toc'" type="button"
            :class="tab==='toc' ? 'bg-white text-navy shadow-sm ring-1 ring-brand-200' : 'text-brand-500 hover:text-navy'"
            class="rounded-md px-2.5 py-1 text-[12px] font-semibold transition">Contents</button>
          <button @click="tab='trace'; traceSeen=trace.length" type="button"
            :class="tab==='trace' ? 'bg-white text-navy shadow-sm ring-1 ring-brand-200' : 'text-brand-500 hover:text-navy'"
            class="relative rounded-md px-2.5 py-1 text-[12px] font-semibold transition">
            Diagnostics
            <span x-show="tab!=='trace' && trace.length>traceSeen"
                  class="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent-500"></span>
          </button>
        </div>
        <span x-show="tab==='toc' && sections.length" class="ml-auto text-[11px] text-brand-400" x-text="sections.length + ' sections'"></span>
        <div x-show="tab==='trace' && trace.length" class="ml-auto flex items-center gap-2">
          <button @click="copyTrace()" type="button"
            class="text-[11px] font-semibold text-brand-400 hover:text-navy transition"
            x-text="traceCopied ? 'Copied ✓' : 'Copy'"></button>
          <span class="text-brand-200">·</span>
          <button @click="clearTrace()" type="button"
            class="text-[11px] text-brand-400 hover:text-navy transition">Clear</button>
        </div>
      </div>

      <!-- CONTENTS: the manual's clean bookmark chapters -->
      <div x-show="tab==='toc'" x-ref="toc" class="flex-1 min-h-0 overflow-y-auto scroll-thin px-2 pb-2">
        <!-- no manual yet -->
        <div x-show="!viewDoc" class="h-full grid place-items-center text-center px-4">
          <p class="text-sm text-brand-400/90">Pick a manual to see its contents.</p>
        </div>
        <!-- manual with no bookmarks -->
        <div x-show="viewDoc && !sections.length" class="px-2 py-3">
          <p class="text-sm text-brand-400/90">No table of contents in this PDF — use the page arrows or just ask.</p>
        </div>
        <!-- the chapters -->
        <template x-for="(s,j) in sections" :key="j">
          <button @click="jumpTo(s.page_start)" type="button"
            :data-toc-active="tocActive(j) ? 'true' : 'false'"
            :class="tocActive(j)
              ? 'bg-brand-50 ring-1 ring-brand-200 text-navy'
              : 'hover:bg-brand-50/70 text-brand-700'"
            class="group w-full text-left rounded-lg px-2.5 py-2 flex items-start gap-2.5 transition">
            <span class="mt-px text-[11px] tabular-nums font-semibold w-5 shrink-0 text-right"
                  :class="tocActive(j) ? 'text-accent-600' : 'text-brand-300'" x-text="j+1"></span>
            <span class="flex-1 min-w-0 text-[13px] leading-snug font-medium" x-text="s.title"></span>
            <span class="text-[11px] tabular-nums text-brand-400 shrink-0 mt-px" x-text="'p.'+s.page_start"></span>
          </button>
        </template>
      </div>

      <!-- DIAGNOSTICS: the agent's full trace — every decision (raw 1B reply +
           parsed tool), search hits, page shown, and terminal result -->
      <div x-show="tab==='trace'" x-ref="trace" class="flex-1 min-h-0 overflow-y-auto scroll-thin px-2 pb-3 font-mono text-[11px] leading-relaxed">
        <div x-show="!trace.length" class="h-full grid place-items-center text-center px-4">
          <p class="text-brand-400/90 font-sans text-sm">No activity yet. Send a request and the agent's full trace — every decision, search and result — shows up here.</p>
        </div>
        <template x-for="(e,i) in trace" :key="i">
          <div class="px-1.5 py-0.5">
            <!-- a turn header: what the mechanic asked -->
            <template x-if="e.kind==='turn'">
              <div class="flex items-center gap-1.5 border-t border-brand-100 pt-2 mt-1.5 first:border-0 first:pt-0 first:mt-0">
                <span class="text-accent-600 font-sans font-bold shrink-0"></span>
                <span class="font-sans font-semibold text-navy truncate" x-text="e.label"></span>
              </div>
            </template>
            <!-- an event row: time · badge · label, with optional detail / raw -->
            <template x-if="e.kind!=='turn'">
              <div class="py-px">
                <div class="flex items-baseline gap-1.5">
                  <span class="text-brand-300 tabular-nums shrink-0" x-text="e.t"></span>
                  <span class="shrink-0 font-semibold uppercase tracking-wide px-1 rounded text-[10px]" :class="e.cls" x-text="e.badge"></span>
                  <span class="text-slate-700 break-words min-w-0" x-text="e.label"></span>
                </div>
                <div x-show="e.detail" class="pl-[5.4rem] text-brand-500 break-words" x-text="e.detail"></div>
                <pre x-show="e.mono" class="pl-[5.4rem] mt-0.5 whitespace-pre-wrap break-words text-slate-500" x-text="e.mono"></pre>
                <!-- the exact prompt fed to the model this step — collapsed by default (it's long) -->
                <details x-show="e.prompt" class="pl-[5.4rem] mt-0.5">
                  <summary class="cursor-pointer select-none text-brand-400 hover:text-navy transition" x-text="`prompt sent to model · ${(e.prompt||'').length} chars`"></summary>
                  <pre class="mt-1 whitespace-pre-wrap break-words text-slate-500 bg-brand-50/60 rounded p-2 ring-1 ring-brand-100" x-text="e.prompt"></pre>
                </details>
              </div>
            </template>
          </div>
        </template>
      </div>

      <!-- command bar: live transcript + the ONE current tool indicator + input -->
      <div class="border-t border-brand-100 p-3 shrink-0 space-y-2.5">
        <!-- last request (what speech-to-text heard, once voice lands) -->
        <div x-show="lastRequest" class="flex items-center gap-2 text-[12px] text-brand-500">
          <i data-lucide="quote" class="w-3.5 h-3.5 text-brand-300 shrink-0"></i>
          <span class="truncate italic" x-text="lastRequest"></span>
        </div>

        <!-- the single status line: animated wrench + live progress while
             working, then the outcome icon + label. ALL progress flows here -->
        <div x-show="activity" class="flex items-center gap-2.5 rounded-2xl bg-brand-50 ring-1 ring-brand-200 px-4 py-3">
          <!-- working: the blue animated wrench (inline so lucide never swaps
               this Alpine-bound node out — that detaches it from scope) -->
          <svg x-show="activity && activity.pending" viewBox="0 0 24 24" fill="none" stroke="currentColor"
               stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
               class="w-4 h-4 shrink-0 text-brand-500 animate-wrench">
            <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
          </svg>
          <!-- settled: the outcome icon (x-html so it re-renders on icon change) -->
          <svg x-show="activity && !activity.pending" viewBox="0 0 24 24" fill="none" stroke="currentColor"
               stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
               class="w-4 h-4 shrink-0 text-accent-600"
               x-html="activity ? (ICONS[activity.icon]||ICONS.info) : ''"></svg>
          <span class="text-sm text-brand-600 min-w-0 flex-1 truncate">
            <span x-text="(activity && activity.pending) ? (status || 'Working…') : (activity?.label || '')"></span>
            <span x-show="activity && !activity.pending && activity.detail" class="text-brand-400" x-text="' · ' + (activity?.detail || '')"></span>
          </span>
        </div>
        <div x-show="!activity" class="text-[12px] text-brand-400/90 px-1 leading-snug">
          Try <em>“go to brake bleeding”</em>, <em>“next page”</em>, <em>“circle the brake hoses”</em>.
        </div>

        <!-- input — type, or tap the mic to talk (transcript flows into the
             SAME send() path as typing) -->
        <div class="flex items-end gap-2 rounded-2xl border border-brand-200 bg-white focus-within:border-brand-400 focus-within:ring-2 focus-within:ring-brand-100 transition px-2.5 py-1.5">
          <textarea x-model="input" @keydown.enter.prevent="send()" rows="1"
            :disabled="!ready"
            placeholder="Type a request…"
            class="flex-1 min-w-0 resize-none bg-transparent px-1.5 py-1.5 text-sm text-navy outline-none placeholder:text-brand-400/70 max-h-24"></textarea>
          <!-- mic: arms always-on listening (Silero VAD → Moonshine, fully
               in-browser). The tap also satisfies the mobile mic-permission
               gesture; the voice stack is lazy-loaded on first use so it never
               touches initial page load. -->
          <button @click="toggleVoice()" :disabled="!ready || voice==='loading'"
            :aria-pressed="listening" aria-label="Voice input"
            :title="voice==='on' ? 'Listening — tap to stop' : (voice==='loading' ? 'Loading voice…' : (voiceErr || 'Tap to talk'))"
            class="shrink-0 grid place-items-center w-9 h-9 rounded-xl transition disabled:cursor-not-allowed"
            :class="voice==='on' ? 'bg-accent-500 text-white animate-pulse'
                  : voice==='loading' ? 'bg-brand-100 text-brand-500 animate-pulse'
                  : voice==='error' ? 'bg-red-100 text-red-600 hover:bg-red-200'
                  : 'text-brand-500 hover:text-navy hover:bg-brand-100'">
            <i data-lucide="audio-lines" class="w-5 h-5"></i>
          </button>
          <button @click="send()" :disabled="loading || !ready || !input.trim()" aria-label="Send"
            class="shrink-0 grid place-items-center w-9 h-9 rounded-xl bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed transition">
            <i data-lucide="arrow-up" class="w-5 h-5"></i>
          </button>
        </div>
        <p x-show="voice==='on'" class="text-[11px] text-accent-600 px-1">Listening — just speak. Tap the mic to stop.</p>
        <p x-show="voice==='loading'" class="text-[11px] text-brand-400 px-1">Loading voice (first time downloads the model)…</p>
        <p x-show="voice==='error' && voiceErr" class="text-[11px] text-red-500 px-1" x-text="'Voice unavailable: ' + voiceErr"></p>
      </div>
    </section>

    <!-- RIGHT: page viewer (rendered page image + SVG circle overlay) -->
    <section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden">
      <!-- toolbar: big targets for shop use -->
      <div x-show="viewDoc" class="flex items-center gap-1 px-2.5 py-1.5 border-b border-brand-100">
        <button @click="goRel(-1)" :disabled="viewPage<=1" aria-label="Previous page"
          class="grid place-items-center w-11 h-11 rounded-xl text-brand-600 hover:bg-brand-100 disabled:opacity-30 disabled:cursor-not-allowed transition">
          <i data-lucide="chevron-left" class="w-6 h-6"></i>
        </button>
        <button @click="goRel(1)" :disabled="viewPage>=docPages" aria-label="Next page"
          class="grid place-items-center w-11 h-11 rounded-xl text-brand-600 hover:bg-brand-100 disabled:opacity-30 disabled:cursor-not-allowed transition">
          <i data-lucide="chevron-right" class="w-6 h-6"></i>
        </button>
        <div class="text-sm font-semibold text-navy whitespace-nowrap px-1.5">
          p. <span x-text="viewPage"></span><span class="text-brand-400 font-medium"> / <span x-text="docPages"></span></span>
        </div>
        <div class="flex-1 min-w-0 text-[12px] text-brand-500/90 truncate text-right pr-1" x-text="sectionTitle(viewPage)"></div>
        <button x-show="navStack.length" @click="goBack()" aria-label="Back" title="Back to previous spot"
          class="grid place-items-center w-11 h-11 rounded-xl text-brand-600 hover:bg-brand-100 transition">
          <i data-lucide="undo-2" class="w-5 h-5"></i>
        </button>
      </div>

      <!-- the page(s): a book-style spread — the active page plus the next one
           for context (always two pages, except on the last page). Each slot is
           a full-height flex column; the image fits the slot's HEIGHT and (in
           spread) at most half the width, so both facing pages stay fully
           visible without blowing up. img + svg are stacked in one grid cell
           and both carry the page's aspect-ratio + max constraints, so they
           compute the SAME box and the circle overlay stays aligned 1:1. -->
      <div class="flex-1 min-h-0 overflow-auto scroll-thin bg-brand-50/40 flex justify-center items-stretch gap-3 p-4">
        <template x-for="p in spreadPages" :key="p">
          <div class="relative flex-1 min-w-0 grid place-items-center">
            <img :src="`/page/${encodeURIComponent(viewDoc)}/${p}?v=__PAGE_V__`"
                 @load="nat[p]={w:$event.target.naturalWidth,h:$event.target.naturalHeight}; if(p===viewPage){imgW=$event.target.naturalWidth; imgH=$event.target.naturalHeight;}"
                 class="col-start-1 row-start-1 block max-h-full max-w-full w-auto h-auto select-none rounded-lg shadow-sm ring-1 ring-navy/10"
                 draggable="false" alt="manual page">
            <!-- box overlay: a rounded rectangle hugging the grounded region,
                 padded slightly and CLAMPED to the page so it never runs off
                 (an ellipse circumscribing the box ballooned wide boxes into a
                 page-spanning arc). bbox coords are in this page's rendered
                 pixel space (= its natural size); only the page the box is FOR
                 shows it. No <template x-if> inside <svg> (HTML templates aren't
                 valid SVG children) — the rect always exists, bindings null-guard. -->
            <!-- viewBox MUST be set imperatively with its exact camelCase name:
                 the HTML parser lowercases the `:viewBox` directive to `:viewbox`,
                 so Alpine writes a lowercase `viewbox` attribute, which SVG
                 ignores (the attribute is case-sensitive). With no viewBox the
                 rect renders in raw page-pixel units inside an unscaled SVG and
                 falls off-screen. x-effect re-runs when boxDims() changes. -->
            <svg x-show="circle && circle.page===p"
                 x-effect="$el.setAttribute('viewBox', `0 0 ${boxDims(p).w} ${boxDims(p).h}`)"
                 :style="`aspect-ratio:${boxDims(p).w}/${boxDims(p).h}`"
                 class="col-start-1 row-start-1 max-h-full max-w-full w-auto h-auto pointer-events-none">
              <rect :x="boxRect(p).x" :y="boxRect(p).y" :width="boxRect(p).w" :height="boxRect(p).h"
                    rx="14" ry="14" fill="#f59e0b" fill-opacity="0.22"/>
            </svg>
          </div>
        </template>
        <div x-show="!viewDoc" class="h-full w-full grid place-items-center text-center px-8">
          <div>
            <i data-lucide="file-text" class="inline-block w-11 h-11 text-brand-300 mb-3"></i>
            <p class="text-brand-500/80 text-sm max-w-xs">Pick a manual and it opens here — then say things like <em>go to the brakes section</em> or <em>next page</em>.</p>
          </div>
        </div>
      </div>

    </section>
  </main>

  <!-- Settings modal -->
  <div x-show="settingsOpen" x-cloak
       class="fixed inset-0 z-50 flex items-center justify-center p-4"
       x-transition.opacity>
    <div class="absolute inset-0 bg-navy/50" @click="settingsOpen=false"></div>
    <div class="relative w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-navy/10 p-6"
         x-transition:enter="transition ease-out duration-200"
         x-transition:enter-start="opacity-0 translate-y-2 scale-95"
         x-transition:enter-end="opacity-100 translate-y-0 scale-100">
      <div class="flex items-center justify-between mb-5">
        <h2 class="text-base font-bold text-navy">Settings</h2>
        <button @click="settingsOpen=false" aria-label="Close" class="grid place-items-center w-8 h-8 rounded-lg text-brand-400 hover:bg-brand-100 hover:text-navy transition">
          <i data-lucide="x" class="w-5 h-5"></i>
        </button>
      </div>

      <!-- k: search shortlist size the agent reranks -->
      <div>
        <div class="flex items-center justify-between mb-1.5">
          <label class="text-xs font-semibold uppercase tracking-wide text-brand-500/80">Pages retrieved (k)</label>
          <span class="text-sm font-bold text-accent-600" x-text="k"></span>
        </div>
        <input type="range" min="1" :max="maxK" step="1" x-model.number="k"
               class="w-full accent-brand-600">
      </div>

      <!-- agent brain: which model drives the find-and-point loop -->
      <div class="mt-5">
        <label class="block text-xs font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Agent brain</label>
        <div class="relative">
          <select x-model="agentModel"
            class="w-full appearance-none rounded-xl border border-brand-200 bg-brand-50/50 px-3.5 py-2.5 pr-9 text-sm font-medium text-navy focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition">
            <template x-for="m in agentModels" :key="m.key">
              <option :value="m.key" x-text="m.label"></option>
            </template>
          </select>
          <i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-brand-400"></i>
        </div>
        <p class="mt-1 text-xs text-brand-400">The model that finds pages and points. Switching loads it fresh — the first turn after a change is slower.</p>
      </div>

      <!-- search index: which index the search tool ranks the query against. The
           big brains (forbidVisual) can't keep ColEmbed resident, so Visual is
           disabled and force-reset to Parsed while one of them is the agent. -->
      <div class="mt-5"
           x-effect="if((agentModels.find(x=>x.key===agentModel)||{}).forbidVisual && retrievalMode==='visual') retrievalMode='parsed'">
        <label class="block text-xs font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Search index</label>
        <div class="relative">
          <select x-model="retrievalMode"
            class="w-full appearance-none rounded-xl border border-brand-200 bg-brand-50/50 px-3.5 py-2.5 pr-9 text-sm font-medium text-navy focus:border-brand-400 focus:ring-2 focus:ring-brand-100 outline-none transition">
            <template x-for="m in retrievalModes" :key="m.key">
              <option :value="m.key" x-text="m.label"
                :disabled="m.key==='visual' && (agentModels.find(x=>x.key===agentModel)||{}).forbidVisual"></option>
            </template>
          </select>
          <i data-lucide="chevron-down" class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-brand-400"></i>
        </div>
        <p class="mt-1 text-xs text-brand-400">What the search tool ranks against. Parsed (text) wins on specs and tables; Visual (ColEmbed) on diagrams.<span x-show="(agentModels.find(x=>x.key===agentModel)||{}).forbidVisual" class="text-amber-600"> Visual is unavailable with the current brain (too large to load ColEmbed alongside it).</span></p>
      </div>

      <!-- think: let the VLM reason before committing to a circle box -->
      <div class="mt-5 flex items-start justify-between gap-3">
        <div>
          <label class="text-xs font-semibold uppercase tracking-wide text-brand-500/80">Careful pointing</label>
          <p class="mt-0.5 text-xs text-brand-400">VLM reasons before circling — more accurate on diagrams, but slower.</p>
        </div>
        <button type="button" role="switch" :aria-checked="think" @click="think=!think"
                :class="think ? 'bg-brand-600' : 'bg-brand-200'"
                class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition mt-0.5">
          <span :class="think ? 'translate-x-5' : 'translate-x-1'"
                class="inline-block h-4 w-4 rounded-full bg-white shadow transition"></span>
        </button>
      </div>

      <!-- speak back: read each turn's one-line reply aloud (browser TTS) -->
      <div class="mt-5 flex items-start justify-between gap-3">
        <div>
          <label class="text-xs font-semibold uppercase tracking-wide text-brand-500/80">Speak responses</label>
          <p class="mt-0.5 text-xs text-brand-400">Reads each reply aloud for hands-free feedback. Voice varies by device.</p>
        </div>
        <button type="button" role="switch" :aria-checked="speakBack" @click="toggleSpeak()"
                :class="speakBack ? 'bg-brand-600' : 'bg-brand-200'"
                class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition mt-0.5">
          <span :class="speakBack ? 'translate-x-5' : 'translate-x-1'"
                class="inline-block h-4 w-4 rounded-full bg-white shadow transition"></span>
        </button>
      </div>

      <!-- VRAM logging: server-side GPU-memory snapshots, for debugging the budget -->
      <div class="mt-5 flex items-start justify-between gap-3">
        <div>
          <label class="text-xs font-semibold uppercase tracking-wide text-brand-500/80">Log VRAM</label>
          <p class="mt-0.5 text-xs text-brand-400">Logs GPU memory at each step (resident models, brain load/evict, grounding) to the server logs. For debugging.</p>
        </div>
        <button type="button" role="switch" :aria-checked="vramLog" @click="vramLog=!vramLog"
                :class="vramLog ? 'bg-brand-600' : 'bg-brand-200'"
                class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition mt-0.5">
          <span :class="vramLog ? 'translate-x-5' : 'translate-x-1'"
                class="inline-block h-4 w-4 rounded-full bg-white shadow transition"></span>
        </button>
      </div>

      <div class="flex gap-2 mt-6">
        <button @click="refresh()" class="flex-1 rounded-xl border border-brand-200 px-4 py-2.5 text-sm font-medium text-navy hover:bg-brand-50 transition flex items-center justify-center gap-1.5">
          <i data-lucide="refresh-cw" class="w-4 h-4"></i>
          Sync library
        </button>
        <button @click="settingsOpen=false" class="flex-1 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-700 transition">Done</button>
      </div>
    </div>
  </div>
</div>

<script>
// Inline Lucide paths for the dynamic tool indicator. lucide.createIcons()
// replaces an <i> with an <svg> ONCE, after which Alpine can't swap its icon
// on reactive changes — so the evolving chip renders its icon via x-html into
// a host <svg> instead, which re-renders every time activity.icon changes.
const ICONS = {
  loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
  target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
  bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
  'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
  'undo-2': '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 5.5 5.5 0 0 1-5.5 5.5H11"/>',
  'circle-slash': '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
  info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
  'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
};

function repairGuy() {
  return {
    client:null, ready:false,
    // One id per page load — groups this visit's find turns into a single
    // Langfuse session so a whole conversation can be replayed together.
    sessionId:(self.crypto&&crypto.randomUUID)?crypto.randomUUID():'sess-'+Date.now()+'-'+Math.random().toString(36).slice(2),
    manuals:[], manual:'',
    k:__DEFAULT_K__, maxK:__MAX_K__,
    // When on, MiniCPM-V reasons before committing to a circle box (slower,
    // helps diagram-callout targets). Sent as `think` with each /find turn;
    // initialized from the server default.
    think:__GROUND_THINK__,
    // The agent "brain" picker: the selectable models (key+label) and the
    // current choice, sent as `agent_model` each turn. Switching one in evicts
    // the previous from VRAM server-side (load-on-switch), so the FIRST turn
    // after a change is slower while the new model loads.
    agentModels:__AGENT_MODELS_JSON__, agentModel:'__AGENT_MODEL__',
    // The search-index picker: which index the search tool ranks against
    // (visual / parsed), sent as `retrieval_mode` each turn. Parsed wins on
    // spec/table lookups, visual on diagrams; initialized from the server default.
    retrievalModes:__RETRIEVAL_MODES_JSON__, retrievalMode:'__RETRIEVAL_MODE__',
    // Diagnostics: when on, the server logs a GPU-memory snapshot at each step of
    // the turn (resident models, brain evict/load, the grounding spike). Sent as
    // `vram_log`; off by default since it's only for debugging the VRAM budget.
    vramLog:false,
    input:'', loading:false, status:'',
    settingsOpen:false,
    // When on, the terminal one-liner for each turn (the activity label) is read
    // aloud via the browser's built-in speechSynthesis — no deps, no server cost,
    // fully client-side like the STT. In voice mode the mic is paused while we
    // talk so the VAD never transcribes our own voice. Off by default.
    speakBack:false,
    ICONS,
    // The command bar shows only the last request (the speech-to-text
    // transcript, once voice lands) and `activity`: the single, evolving
    // indicator of the current tool call / one-line reply. `history` is the
    // compact memory of past turns ([{request, action}]) sent with each turn so
    // the agent can resolve references ("circle the other one", "go back").
    lastRequest:'', activity:null, history:[],
    // voice mode: always-on, fully in-browser (Silero VAD segments speech →
    // Moonshine transcribes → the transcript is fed into send() like typed
    // text). Lazy-loaded on first mic tap; `voice` is the state machine
    // (idle | loading | on | error). vad/asr are the one-time-built singletons.
    voice:'idle', voiceErr:'', vad:null, asr:null, wakeLock:null,
    // left-rail tabs: 'toc' (the manual's contents) | 'trace' (the agent's full
    // diagnostic trace for the session). `trace` accumulates structured entries;
    // `traceSeen` drives the unseen-activity dot on the Diagnostics tab.
    tab:'toc', trace:[], traceSeen:0, traceCopied:false,
    // viewer state machine: which page is open, the manual's chapter index,
    // a jump history for "back", and the active circle overlay.
    viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null,
    imgW:1240, imgH:1754,  // the ACTIVE (left) page's natural size; fallback for the overlay
    spread:true,           // book-style two-page view (active page + the next)
    nat:{},                // page number -> {w,h} natural size, set on each image load

    get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}?v=__PAGE_V__` : ''; },
    // the page(s) on screen: the active page, plus the next one as context in
    // spread mode (none past the last page). The active page is always LEFT, so
    // the agent's target/circle page stays the first element.
    get spreadPages(){ if(!this.viewDoc) return []; const p=this.viewPage; return (this.spread && p<this.docPages) ? [p, p+1] : [p]; },
    natOf(p){ return this.nat[p] || {w:this.imgW, h:this.imgH}; },
    // The coordinate space the active circle's bbox lives in: the size of the
    // image the VLM GROUNDED on (sent by the backend), NOT the browser-loaded
    // page <img>. Using this for the SVG viewBox keeps the circle aligned even
    // if the served page PNG is a different/stale resolution. Falls back to the
    // loaded image's natural size (older events without dims).
    boxDims(p){ const c=this.circle; return (c && c.page===p && c.dims) ? {w:c.dims[0], h:c.dims[1]} : this.natOf(p); },
    // the overlay rectangle for the active circle on page p: bbox + a small pad,
    // normalized (x1<x2,y1<y2) and clamped to the page so it never runs off.
    // Gate on page match too (not just the <svg>'s x-show): when the spread
    // shifts as the circle lands (e.g. [614,615] → [615,616]), Alpine's keyed
    // x-for can leave the OTHER page's overlay <svg> at display:block, and an
    // ungated boxRect would then draw this same bbox on BOTH pages. Returning an
    // empty rect for the non-matching page keeps the box on its page regardless.
    boxRect(p){
      if(!this.circle || !this.circle.bbox || this.circle.page!==p) return {x:0,y:0,w:0,h:0};
      const b=this.circle.bbox, n=this.boxDims(p), pad=14;
      const x1=Math.min(b[0],b[2]), y1=Math.min(b[1],b[3]), x2=Math.max(b[0],b[2]), y2=Math.max(b[1],b[3]);
      const x=Math.max(0,x1-pad), y=Math.max(0,y1-pad);
      return {x, y, w:Math.min(n.w,x2+pad)-x, h:Math.min(n.h,y2+pad)-y};
    },

    async init(){
      window.lucide?.createIcons();  // swap static <i data-lucide> -> clean SVG icons
      try{
        const mod = await import('https://cdn.jsdelivr.net/npm/@gradio/client/+esm');
        const Client = mod.Client || mod.client;
        this.client = Client.connect ? await Client.connect(location.origin) : await Client(location.origin);
        this.ready = true;
        await this.loadManuals();
      }catch(e){ console.error('connect failed', e); }
    },

    async loadManuals(){
      try{ const r = await this.client.predict('/manuals', {}); this.manuals = r.data?.[0] || []; }
      catch(e){ console.error(e); }
    },
    async refresh(){
      try{ const r = await this.client.predict('/refresh', {}); this.manuals = r.data?.[0] || []; }
      catch(e){ console.error(e); }
    },

    // re-render the static lucide icons that appear after the first pass
    icons(){ this.$nextTick(()=> window.lucide?.createIcons()); },
    // keep the active chapter visible as the viewer moves
    scrollTocActive(){ this.$nextTick(()=>{ this.$refs.toc?.querySelector('[data-toc-active="true"]')?.scrollIntoView({block:'nearest'}); }); },

    // switching manuals starts fresh and opens the manual on page 1
    async newChat(){
      this.lastRequest=''; this.activity=null; this.history=[];
      this.trace=[]; this.traceSeen=0; this.tab='toc';
      this.viewDoc=''; this.viewPage=1; this.navStack=[]; this.circle=null; this.sections=[]; this.nat={};
      const m = this.manuals.find(x=>x.value===this.manual);
      if(!m) return;
      this.docPages = m.pages || 1;
      this.viewDoc = this.manual;
      try{
        const r = await fetch(`/sections/${encodeURIComponent(this.manual)}`);
        this.sections = (await r.json()).sections || [];
      }catch(e){ console.error(e); }
      this.icons(); this.scrollTocActive();
    },

    // --- viewer state machine -----------------------------------------
    sectionTitle(p){ let t=''; for(const s of this.sections){ if(s.page_start<=p) t=s.title; else break; } return t; },
    sectionIndex(p){ let i=-1; for(let j=0;j<this.sections.length;j++){ if(this.sections[j].page_start<=p) i=j; else break; } return i; },
    tocActive(j){ return j===this.sectionIndex(this.viewPage); },

    setPage(p, push=false){
      p = Math.max(1, Math.min(this.docPages, p));
      if(push && this.viewDoc && p!==this.viewPage) this.navStack.push(this.viewPage);
      if(this.circle && this.circle.page!==p) this.circle=null;
      this.viewPage = p; this.viewDoc = this.manual;
      this.scrollTocActive();
    },
    goRel(d){ this.setPage(this.viewPage+d); },
    jumpTo(p){ this.setPage(p, true); },
    goBack(){ const p=this.navStack.pop(); if(p) this.setPage(p); },

    // the single live indicator: a tool chip, or a one-line spoken-style reply
    chip(icon, label, detail=''){ this.activity={icon, label, detail, pending:false}; this.icons(); this.speak(label); },
    say(t){ this.activity={icon:'info', label:t, detail:'', pending:false}; this.icons(); this.speak(t); },

    // flip the toggle; turning it off cuts any in-flight utterance immediately.
    toggleSpeak(){ this.speakBack=!this.speakBack; if(!this.speakBack && window.speechSynthesis) speechSynthesis.cancel(); },

    // read a short phrase aloud via the browser's built-in speechSynthesis.
    // No-op unless the toggle is on. In voice mode the mic is paused for the
    // duration so the always-on VAD doesn't hear (and transcribe) our own voice
    // — re-armed on end/error. Emoji are stripped (TTS reads them as junk).
    speak(text){
      if(!this.speakBack || !text) return;
      const synth = window.speechSynthesis;
      if(!synth) return;
      const clean = String(text).replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{FE0F}]/gu,'').trim();
      if(!clean) return;
      synth.cancel();  // drop any stale phrase before re-arming the mic
      const live = this.voice==='on';
      if(live){ try{ this.vad?.pause(); }catch(_){} }
      const resume = ()=>{ if(live && this.voice==='on'){ try{ this.vad?.start(); }catch(_){} } };
      const u = new SpeechSynthesisUtterance(clean);
      u.rate = 1.05;            // a touch brisk reads as responsive
      u.onend = resume; u.onerror = resume;
      synth.speak(u);
    },

    // compact memory of past turns sent with each find (last 8 kept)
    recordHistory(action){
      if(!this.lastRequest || !action) return;
      this.history.push({request:this.lastRequest, action});
      if(this.history.length>8) this.history.shift();
    },

    // --- diagnostic trace ---------------------------------------------
    // The Diagnostics tab is a session-long log of what the agent actually did:
    // each turn's request, every step's raw 1B reply + parsed tool, the search
    // hits, the page shown, and the terminal result. Distinct from `history`
    // (which is the compact memory SENT to the model) and from `activity` (the
    // single user-facing chip) — this is the developer's-eye view.
    now(){ const d=new Date(); return d.toTimeString().slice(0,8)+'.'+String(d.getMilliseconds()).padStart(3,'0'); },
    pushTrace(e){
      e.t = this.now();
      this.trace.push(e);
      if(this.trace.length>500) this.trace.splice(0, this.trace.length-500);
      if(this.tab==='trace'){ this.traceSeen=this.trace.length; this.$nextTick(()=>{ const el=this.$refs.trace; if(el) el.scrollTop=el.scrollHeight; }); }
    },
    clearTrace(){ this.trace=[]; this.traceSeen=0; },

    // flatten the trace to plain text for pasting back into a bug report
    traceText(){
      const body = this.trace.map(e=>{
        if(e.kind==='turn') return `\n▶ ${e.label}`;
        let s = `  ${e.t}  ${String(e.badge||'').toUpperCase().padEnd(7)} ${e.label||''}`;
        if(e.detail) s += `\n            ${e.detail}`;
        if(e.mono)   s += `\n            ${String(e.mono).replace(/\n/g,'\n            ')}`;
        if(e.prompt) s += `\n            ┌─ prompt sent to model ─\n${String(e.prompt).replace(/^/gm,'            │ ')}\n            └─`;
        return s;
      }).join('\n');
      return `Repair Guy trace — ${this.manual||'(no manual)'}\n${body}`.trim()+'\n';
    },
    copyTrace(){
      const text = this.traceText();
      const done = ()=>{ this.traceCopied=true; setTimeout(()=>{ this.traceCopied=false; }, 1500); };
      const fallback = ()=>{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); }catch(_){} document.body.removeChild(ta); done(); };
      if(navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done).catch(fallback);
      else fallback();
    },
    traceTurn(q){ this.pushTrace({kind:'turn', label:q}); },
    traceLocal(){ this.pushTrace({kind:'local', badge:'nav', cls:'bg-slate-100 text-slate-600', label:(this.activity?.label||'navigated'), detail:this.activity?.detail||''}); },

    // map one wire event → a trace row (or none). Mirrors handleFindEvent but
    // keeps the raw detail the chip throws away.
    recordTrace(ev){
      if(ev.error){ this.pushTrace({kind:'error', badge:'error', cls:'bg-red-100 text-red-700', label:ev.error}); return; }
      switch(ev.type){
        case 'trace':{  // a decision: prompt sent in + raw 1B reply + parsed tool
          const tool = ev.tool ? JSON.stringify(ev.tool) : '(unparseable — retrying)';
          this.pushTrace({kind:'decision', badge:`step ${ev.step}`, cls:'bg-brand-100 text-brand-700', label:`decided ${tool}`, mono:ev.raw||'', prompt:ev.prompt||''});
          break;
        }
        case 'step':{  // the chosen tool, with its args
          let label='';
          if(ev.tool==='search') label=`search · “${ev.query}”`;
          else if(ev.tool==='circle') label=`circle · “${ev.target}”`;
          else if(ev.tool==='go_to_section') label=`go_to_section · ${ev.title} (p.${ev.page})`;
          else if(ev.tool==='go_to_page') label=`go_to_page · p.${ev.page}`;
          this.pushTrace({kind:'step', badge:'tool', cls:'bg-brand-600 text-white', label});
          break;
        }
        case 'tool_result':
          if(ev.tool==='search_docs'){
            const lines = (ev.thumbnails||[]).map(t=>{ const m=(t.caption||'').match(/score\s*([\d.]+)/i); return `p.${t.page}${m?'  '+m[1]:''}`; });
            this.pushTrace({kind:'search', badge:'hits', cls:'bg-indigo-100 text-indigo-700', label:`${lines.length} candidate${lines.length===1?'':'s'}`, detail:lines.join('   ')});
          }
          break;
        case 'found':
          this.pushTrace({kind:'found', badge:'page', cls:'bg-emerald-100 text-emerald-700', label:`show p.${ev.page}`});
          break;
        case 'status':
          this.pushTrace({kind:'status', badge:'··', cls:'bg-slate-100 text-slate-500', label:ev.text||''});
          break;
        case 'done':{
          const meta = ev.elapsed!=null ? `${ev.elapsed}s` : '';
          let label='', detail='';
          if(ev.kind==='navigate'){ label = ev.nav==='page' ? `go to page → p.${ev.page}` : `navigate → ${ev.title} (p.${ev.page})`; }
          else if(ev.kind==='reply'){ label=`reply · ${ev.message||''}`; }
          else {  // point
            label = ev.found ? `point · “${ev.target}” p.${ev.page}` : `not found · “${ev.target}”`;
            detail = ev.bbox ? `bbox [${ev.bbox.join(', ')}]` : (ev.found ? 'no bbox (page shown, not pinpointed)' : '');
            if(ev.ground_raw) detail += (detail?'   ':'')+`vlm: ${ev.ground_raw}`;
          }
          this.pushTrace({kind:'done', badge:'done', cls:'bg-accent-500 text-white', label, detail:[detail, meta].filter(Boolean).join('   ')});
          break;
        }
      }
    },

    // --- obvious navigation (handled locally, no server call) ----------
    routeLocal(q){
      const s = q.toLowerCase().replace(/[.!?]+\s*$/,'').replace(/\s+/g,' ').trim();
      let m;
      if(/^(?:go |move )?(?:to )?(?:the )?next(?: page)?$|^forward(?: a page)?$/.test(s)){ this.goRel(1); this.chip('file-text', `Page ${this.viewPage}`, this.sectionTitle(this.viewPage)); this.recordHistory(`turned to p.${this.viewPage}`); return true; }
      if(/^(?:go |move )?(?:to )?(?:the )?prev(?:ious)? page$|^back (?:a |one )?page$/.test(s)){ this.goRel(-1); this.chip('file-text', `Page ${this.viewPage}`, this.sectionTitle(this.viewPage)); this.recordHistory(`turned to p.${this.viewPage}`); return true; }
      if(/^(?:go |take me )?back$/.test(s)){ this.goBack(); this.chip('undo-2', `Back to page ${this.viewPage}`); this.recordHistory(`went back to p.${this.viewPage}`); return true; }
      if(m = s.match(/^(?:go to |open |show me |show |turn to )?(?:page |p\.? ?)(\d+)$/)){ this.setPage(+m[1], true); this.chip('file-text', `Page ${this.viewPage}`, this.sectionTitle(this.viewPage)); this.recordHistory(`went to p.${this.viewPage}`); return true; }
      if(m = s.match(/^(?:go to |move to |skip to )?(?:the )?(next|prev(?:ious)?) (?:section|chapter)$/)){
        const i = this.sectionIndex(this.viewPage), d = m[1]==='next' ? 1 : -1;
        const t = this.sections[i+d];
        if(t){ this.setPage(t.page_start, true); this.chip('bookmark', t.title, `p.${t.page_start}`); this.recordHistory(`went to section ${t.title} (p.${t.page_start})`); }
        else this.say(d>0 ? 'Already at the last section.' : 'Already at the first section.');
        return true;
      }
      if(/^(where am i|what page is this|what section is this|what section am i in)$/.test(s)){
        const t = this.sectionTitle(this.viewPage);
        this.say(t ? `You're on p. ${this.viewPage}${t}.` : `You're on p. ${this.viewPage}.`);
        return true;
      }
      return false;
    },

    async send(){
      const q = this.input.trim();
      if(!q || this.loading || !this.ready) return;
      this.lastRequest = q; this.input='';
      if(!this.manual){ this.say('Pick a manual first ☝️'); return; }
      this.traceTurn(q);
      if(this.routeLocal(q)){ this.traceLocal(); return; }  // obvious nav — done client-side

      // everything else is a find-and-point turn on the GPU
      this.loading=true; this.status='Working…';
      const find = this.startFindChip();
      // surface the viewer state we send the model this turn — whatever page the
      // user is on now (however they got there: toolbar, TOC, typed nav) is read
      // fresh here, so the prompt always reflects the CURRENT page + its text
      {
        const sec = this.sectionTitle(this.viewPage);
        this.pushTrace({kind:'viewer', badge:'view', cls:'bg-sky-100 text-sky-700',
          label:`current page ${this.spreadPages.map(p=>'p.'+p).join(' + ')}${sec?` · ${sec}`:''}`});
      }
      // surface the compact memory we send the model this turn (the prior
      // turns are also inside the step-0 prompt block, but call them out here)
      if(this.history.length) this.pushTrace({kind:'history', badge:'memory', cls:'bg-violet-100 text-violet-700',
        label:`sending ${this.history.length} prior turn${this.history.length===1?'':'s'} as memory`,
        mono:this.history.map((h,i)=>`${i+1}. asked: ${h.request}\n   did:   ${h.action}`).join('\n')});
      try{
        const job = this.client.submit('/find', {
          request:q, manual:this.manual, k:this.k, think:this.think, agent_model:this.agentModel,
          retrieval_mode:this.retrievalMode,
          session_id:this.sessionId,
          page: this.viewDoc ? this.viewPage : 0, section: this.sectionTitle(this.viewPage),
          pages: this.spreadPages,  // every page currently on screen — the agent may circle on any
          history: this.history,
          vram_log: this.vramLog,  // diagnostics: server logs GPU memory this turn
        });
        // the client re-delivers the generator's last yield in the completion
        // payload, so stop after the first terminal (done/error) event
        let finished = false;
        for await (const msg of job){
          if(finished || msg.type!=='data' || !msg.data?.[0]) continue;
          this.recordTrace(msg.data[0]);
          finished = this.handleFindEvent(msg.data[0], find);
        }
      }catch(e){ this.pushTrace({kind:'error', badge:'error', cls:'bg-red-100 text-red-700', label:String(e)}); find.icon='circle-slash'; find.label='Something went wrong'; find.detail=String(e); find.pending=false; }
      this.speak(find.label);  // read the turn's terminal one-liner aloud (if enabled)
      this.loading=false; this.status='';
    },

    // seed the single live chip for a find turn; its icon/label evolve as
    // events arrive. `activity` is a reactive property, so mutating the object
    // it points at re-renders.
    startFindChip(){
      this.activity = {tool:'find', icon:'loader', label:'Working…', detail:'', pending:true};
      return this.activity;
    },

    // returns true on a terminal event (done or error)
    handleFindEvent(ev, find){
      if(ev.error){ find.icon='circle-slash'; find.label=ev.error.replace(/^⚠️\s*/,''); find.detail=''; find.pending=false; return true; }
      switch(ev.type){
        case 'status':
          this.status = ev.text || 'Working…';
          break;

        case 'step':  // the agent picked a tool; evolve the single chip
          if(ev.tool==='go_to_section'){ find.icon='bookmark'; find.label=`Going to ${ev.title}`; find.detail=`p.${ev.page}`; this.status=`Opening ${ev.title}…`; }
          else if(ev.tool==='go_to_page'){ find.icon='file-text'; find.label=`Going to page ${ev.page}`; find.detail=''; this.status=`Opening page ${ev.page}…`; }
          else if(ev.tool==='circle'){ find.icon='target'; find.label=`Circling “${ev.target}”`; find.detail=''; this.status=`Looking for “${ev.target}”…`; }
          else if(ev.tool==='search'){ find.icon='search'; find.label=`Searching: “${ev.query}”`; find.detail=''; this.status=`Searching the manual for “${ev.query}”…`; }
          break;

        case 'tool_result':  // search candidates (no longer shown — keep status only)
          if(ev.tool==='search_docs'){
            const n = (ev.thumbnails||[]).length;
            this.status = `Reading ${n||''} candidate page${n===1?'':'s'}…`.replace(/\s+/g,' ');
          }
          break;

        case 'found':
          this.status = `Showing p.${ev.page}…`;
          this.setPage(ev.page, true);  // show the matching page now
          break;

        case 'done': {
          const meta = ev.elapsed!=null ? ` · ${ev.elapsed}s` : '';
          if(ev.kind==='navigate'){
            this.setPage(ev.page, true);
            if(ev.nav==='page'){
              find.icon='file-text'; find.label=`Page ${ev.page}`; find.detail=meta.replace(/^ · /,'');
              this.recordHistory(`went to p.${ev.page}`);
            }else{
              find.icon='bookmark'; find.label=ev.title; find.detail=`p.${ev.page}${meta}`;
              this.recordHistory(`went to section ${ev.title} (p.${ev.page})`);
            }
          }else if(ev.kind==='reply'){
            find.icon='info'; find.label=ev.message||'Done'; find.detail=meta.replace(/^ · /,'');
            this.recordHistory(ev.message||'done');
          }else if(ev.found){  // point
            // Set the circle BEFORE navigating: setPage(ev.page) changes
            // viewPage, which rebuilds the x-for page slots; if circle isn't set
            // yet, the freshly-built overlay <svg> can miss the circle state and
            // intermittently not render. setPage only clears the circle when its
            // page differs (here it's the same), so setting it first is safe.
            if(ev.bbox){
              this.circle={page:ev.page, bbox:ev.bbox, dims:ev.dims};
              this.setPage(ev.page);
              find.icon='target'; find.label=`Circled “${ev.target}”`; this.recordHistory(`circled “${ev.target}” on p.${ev.page}`); }
            else { this.setPage(ev.page); find.icon='file-text'; find.label=`Showed “${ev.target}”`; this.recordHistory(`showed “${ev.target}” on p.${ev.page}`); }  // page found, not pinpointed
            find.detail=`p.${ev.page}${meta}`;
          }else{
            find.icon='circle-slash'; find.label=`Couldn't find “${ev.target}”`; find.detail=meta.replace(/^ · /,'');
            this.recordHistory(`couldn't find “${ev.target}”`);
          }
          find.pending=false;
          return true;
        }
      }
      return false;
    },

    // --- voice mode: always-on listening, fully in-browser ------------
    // Tap the mic to arm; Silero VAD (vendored, runs on onnxruntime-web's
    // wasm) listens continuously and fires onSpeechEnd when an utterance
    // finishes — that's the "send". The clip is transcribed by Moonshine
    // (transformers.js) and dropped into send() exactly like typed text, so
    // the find/nav pipeline downstream is untouched. Nothing leaves the
    // browser. The whole stack is lazy-loaded on first tap, so it never
    // affects initial page load or the type-only flow.
    get listening(){ return this.voice==='on'; },

    async toggleVoice(){
      if(this.voice==='loading') return;
      if(this.voice==='on'){ await this.voiceStop(); return; }
      await this.voiceStart();
    },

    async voiceStart(){
      this.voice='loading'; this.voiceErr='';
      try{
        if(!this.vad) await this.voiceInit();  // one-time: libs + models + mic
        await this.vad.start();
        this.acquireWakeLock();                // keep the screen awake while listening (mobile)
        this.voice='on';
      }catch(e){
        console.error('voice start failed', e);
        this.voiceErr=(e && e.message) || String(e); this.voice='error';
      }
    },

    async voiceStop(){
      try{ this.vad?.pause(); }catch(_){}      // keep the instance warm; re-arm is instant
      this.releaseWakeLock();
      this.voice='idle';
    },

    // one-time heavy setup, run on the first mic tap
    async voiceInit(){
      // 1) self-hosted onnxruntime-web (global `ort`) + Silero VAD (global `vad`),
      //    loaded as injected <script>s so they stay out of initial page load.
      //    Single-threaded → no SharedArrayBuffer, so no cross-origin isolation
      //    is needed inside the HF Spaces iframe.
      //
      //    VERSION COUPLING: vad/bundle.min.js has its OWN onnxruntime-web baked
      //    in (currently 1.22.0) and fetches the wasm binary from onnxWASMBasePath
      //    below (/vendor/onnx/). The .wasm/.mjs files there MUST match that ORT
      //    version — a mismatch dies at session creation with cryptic glue errors
      //    ("t.getValue is not a function" was 1.22 JS + 1.19 wasm). If you ever
      //    re-vendor vad's bundle, re-pull onnxruntime-web@<same-version>/dist/
      //    {ort-wasm-simd-threaded.wasm,ort-wasm-simd-threaded.mjs,ort.wasm.min.js}.
      await this.loadScript('/vendor/onnx/ort.wasm.min.js');
      window.ort.env.wasm.numThreads = 1;
      window.ort.env.wasm.wasmPaths = '/vendor/onnx/';
      await this.loadScript('/vendor/vad/bundle.min.js');

      // 2) Moonshine ASR — transformers.js library from CDN (pinned), but the
      //    MODEL is SELF-HOSTED under /vendor/moonshine-tiny-ONNX/ and remote
      //    fetching is OFF. This is deliberate: left to the hub, v4 defaults to
      //    the 4-bit (q4) merged decoder, whose MatMulNBits export is malformed
      //    for onnxruntime-web and dies at session creation ("Missing required
      //    scale … embed_tokens") — and that bad file then sticks in the browser
      //    cache. We ship ONLY the q8 files (verified MatMulNBits-free), so there
      //    is no q4 to pick, no hub round-trip, and no cached-q4 ghost. dtype is
      //    a single string (applied to every session); device defaults to wasm.
      //
      //    graphOptimizationLevel:'disabled' is the load-bearing fix. The q8
      //    decoder ships embed_tokens.weight as a DequantizeLinear weight that
      //    is TIED (reused as the LM-head MatMul). At session creation ORT's
      //    extended-level QDQ pass (TransposeDQWeightsForMatMulNBits) tries to
      //    transpose+fuse that DQ weight into a MatMulNBits op; the transpose
      //    renames the node to …weight_transposed_DequantizeLinear but the scale
      //    is still …weight_merged_0_scale, the two no longer match, and create()
      //    aborts with "Missing required scale". The model is fine — the fusion
      //    is the bug — so we just turn the optimizer off (DQ runs natively).
      const { pipeline, env } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0');
      env.allowRemoteModels = false;   // never touch the hub — local files only
      env.allowLocalModels = true;
      env.localModelPath = '/vendor/'; // model id is appended → /vendor/moonshine-tiny-ONNX/…
      this.asr = await pipeline('automatic-speech-recognition', 'moonshine-tiny-ONNX', {
        dtype: 'q8',
        device: 'wasm',
        session_options: { graphOptimizationLevel: 'disabled' },
      });

      // 3) the mic: Silero VAD segments speech; onSpeechEnd is our send trigger
      this.vad = await window.vad.MicVAD.new({
        baseAssetPath: '/vendor/vad/',
        onnxWASMBasePath: '/vendor/onnx/',
        model: 'v5',
        onSpeechEnd: (audio)=> this.onUtterance(audio),
      });
    },

    // a finished utterance (Float32Array @16kHz) → transcribe → normal send()
    async onUtterance(audio){
      if(this.loading) return;  // a turn is already running; drop this clip (v1)
      let text='';
      try{ const out = await this.asr(audio); text=(out?.text||'').trim(); }
      catch(e){ console.error('transcribe failed', e); return; }
      if(!text) return;
      this.input = text;
      this.send();              // routes locally ("next page") or runs a find turn
    },

    // inject a <script> once and resolve when it has loaded
    loadScript(src){
      return new Promise((resolve, reject)=>{
        if(document.querySelector(`script[data-src="${src}"]`)) return resolve();
        const s=document.createElement('script');
        s.src=src; s.async=true; s.dataset.src=src;
        s.onload=()=>resolve(); s.onerror=()=>reject(new Error('failed to load '+src));
        document.head.appendChild(s);
      });
    },

    async acquireWakeLock(){ try{ this.wakeLock = await navigator.wakeLock?.request('screen'); }catch(_){} },
    releaseWakeLock(){ try{ this.wakeLock?.release(); }catch(_){} this.wakeLock=null; },
  }
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body>
</html>