| <!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: { |
| |
| brand: { |
| 50:'#eff5fb',100:'#dbe8f4',200:'#bcd3e9',300:'#90b6d9',400:'#5e93c2', |
| 500:'#3d79ad',600:'#2f6090',700:'#284f76',800:'#244362',900:'#223a53' |
| }, |
| |
| accent: { 400:'#fbbf24',500:'#f59e0b',600:'#d97706' }, |
| |
| 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} |
| |
| |
| @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%} |
| |
| |
| .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 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 class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[minmax(290px,340px)_1fr] gap-5 pb-5"> |
|
|
| |
| <section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div x-show="tab==='toc'" x-ref="toc" class="flex-1 min-h-0 overflow-y-auto scroll-thin px-2 pb-2"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
| |
| |
| <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"> |
| |
| <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> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <div class="border-t border-brand-100 p-3 shrink-0 space-y-2.5"> |
| |
| <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> |
|
|
| |
| |
| <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"> |
| |
| |
| <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> |
| |
| <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> |
|
|
| |
| |
| <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> |
| |
| |
| |
| |
| <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> |
|
|
| |
| <section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden"> |
| |
| <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> |
|
|
| |
| |
| |
| |
| |
| |
| |
| <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"> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| |
| |
| |
| 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, |
| |
| |
| sessionId:(self.crypto&&crypto.randomUUID)?crypto.randomUUID():'sess-'+Date.now()+'-'+Math.random().toString(36).slice(2), |
| manuals:[], manual:'', |
| k:__DEFAULT_K__, maxK:__MAX_K__, |
| |
| |
| |
| think:__GROUND_THINK__, |
| |
| |
| |
| |
| agentModels:__AGENT_MODELS_JSON__, agentModel:'__AGENT_MODEL__', |
| |
| |
| |
| retrievalModes:__RETRIEVAL_MODES_JSON__, retrievalMode:'__RETRIEVAL_MODE__', |
| |
| |
| |
| vramLog:false, |
| input:'', loading:false, status:'', |
| settingsOpen:false, |
| |
| |
| |
| |
| speakBack:false, |
| ICONS, |
| |
| |
| |
| |
| |
| lastRequest:'', activity:null, history:[], |
| |
| |
| |
| |
| voice:'idle', voiceErr:'', vad:null, asr:null, wakeLock:null, |
| |
| |
| |
| tab:'toc', trace:[], traceSeen:0, traceCopied:false, |
| |
| |
| viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null, |
| imgW:1240, imgH:1754, |
| spread:true, |
| nat:{}, |
| |
| get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}?v=__PAGE_V__` : ''; }, |
| |
| |
| |
| 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}; }, |
| |
| |
| |
| |
| |
| boxDims(p){ const c=this.circle; return (c && c.page===p && c.dims) ? {w:c.dims[0], h:c.dims[1]} : this.natOf(p); }, |
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| 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); } |
| }, |
| |
| |
| icons(){ this.$nextTick(()=> window.lucide?.createIcons()); }, |
| |
| scrollTocActive(){ this.$nextTick(()=>{ this.$refs.toc?.querySelector('[data-toc-active="true"]')?.scrollIntoView({block:'nearest'}); }); }, |
| |
| |
| 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(); |
| }, |
| |
| |
| 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); }, |
| |
| |
| 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); }, |
| |
| |
| toggleSpeak(){ this.speakBack=!this.speakBack; if(!this.speakBack && window.speechSynthesis) speechSynthesis.cancel(); }, |
| |
| |
| |
| |
| |
| 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(); |
| 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; |
| u.onend = resume; u.onerror = resume; |
| synth.speak(u); |
| }, |
| |
| |
| recordHistory(action){ |
| if(!this.lastRequest || !action) return; |
| this.history.push({request:this.lastRequest, action}); |
| if(this.history.length>8) this.history.shift(); |
| }, |
| |
| |
| |
| |
| |
| |
| |
| 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; }, |
| |
| |
| 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||''}); }, |
| |
| |
| |
| 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':{ |
| 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':{ |
| 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 { |
| 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; |
| } |
| } |
| }, |
| |
| |
| 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; } |
| |
| |
| this.loading=true; this.status='Working…'; |
| const find = this.startFindChip(); |
| |
| |
| |
| { |
| 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}`:''}`}); |
| } |
| |
| |
| 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, |
| history: this.history, |
| vram_log: this.vramLog, |
| }); |
| |
| |
| 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); |
| this.loading=false; this.status=''; |
| }, |
| |
| |
| |
| |
| startFindChip(){ |
| this.activity = {tool:'find', icon:'loader', label:'Working…', detail:'', pending:true}; |
| return this.activity; |
| }, |
| |
| |
| 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': |
| 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': |
| 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); |
| 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){ |
| |
| |
| |
| |
| |
| 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}`); } |
| 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; |
| }, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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(); |
| await this.vad.start(); |
| this.acquireWakeLock(); |
| 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(_){} |
| this.releaseWakeLock(); |
| this.voice='idle'; |
| }, |
| |
| |
| async voiceInit(){ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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'); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const { pipeline, env } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0'); |
| env.allowRemoteModels = false; |
| env.allowLocalModels = true; |
| env.localModelPath = '/vendor/'; |
| this.asr = await pipeline('automatic-speech-recognition', 'moonshine-tiny-ONNX', { |
| dtype: 'q8', |
| device: 'wasm', |
| session_options: { graphOptimizationLevel: 'disabled' }, |
| }); |
| |
| |
| this.vad = await window.vad.MicVAD.new({ |
| baseAssetPath: '/vendor/vad/', |
| onnxWASMBasePath: '/vendor/onnx/', |
| model: 'v5', |
| onSpeechEnd: (audio)=> this.onUtterance(audio), |
| }); |
| }, |
| |
| |
| async onUtterance(audio){ |
| if(this.loading) return; |
| 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(); |
| }, |
| |
| |
| 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> |
|
|