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>
|