repair-guy / frontend /index.html
airayven7's picture
Sync from GitHub 0fe9a08
08f584d verified
Raw
History Blame Contribute Delete
59 kB
<!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>