Sync from GitHub f01dcd2
Browse files- frontend/index.html +117 -94
- models/minicpm.py +10 -3
frontend/index.html
CHANGED
|
@@ -30,8 +30,6 @@
|
|
| 30 |
|
| 31 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 32 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 33 |
-
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 34 |
-
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
| 35 |
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
| 36 |
|
| 37 |
<style>
|
|
@@ -42,21 +40,11 @@
|
|
| 42 |
.scroll-thin::-webkit-scrollbar-thumb{background:#b6cbe1;border-radius:9999px}
|
| 43 |
.scroll-thin::-webkit-scrollbar-track{background:transparent}
|
| 44 |
|
| 45 |
-
/*
|
| 46 |
-
.prose-chat{font-size:.925rem;line-height:1.6;color:#26384f}
|
| 47 |
-
.prose-chat p{margin:.35rem 0}
|
| 48 |
-
.prose-chat ol{margin:.35rem 0;padding-left:1.15rem;list-style:decimal}
|
| 49 |
-
.prose-chat ul{margin:.35rem 0;padding-left:1.15rem;list-style:disc}
|
| 50 |
-
.prose-chat li{margin:.15rem 0}
|
| 51 |
-
.prose-chat strong{font-weight:600;color:#1e3553}
|
| 52 |
-
.prose-chat code{background:#dbe8f4;padding:.05rem .3rem;border-radius:.3rem;font-size:.85em}
|
| 53 |
-
.prose-chat .meta{display:inline-block;margin-top:.5rem;font-size:.72rem;color:#7d93ad}
|
| 54 |
-
|
| 55 |
-
/* wrench "tightening a bolt" wiggle for the loading indicator */
|
| 56 |
@keyframes wrench-turn{0%,100%{transform:rotate(-24deg)}50%{transform:rotate(20deg)}}
|
| 57 |
.animate-wrench{animation:wrench-turn 1s ease-in-out infinite;transform-origin:50% 50%}
|
| 58 |
|
| 59 |
-
/* subtle blueprint cross-hatch behind
|
| 60 |
.chat-grid{
|
| 61 |
background-color:#fff;
|
| 62 |
background-image:
|
|
@@ -86,13 +74,13 @@
|
|
| 86 |
</button>
|
| 87 |
</header>
|
| 88 |
|
| 89 |
-
<!-- Main
|
| 90 |
-
<main class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-
|
| 91 |
|
| 92 |
-
<!-- LEFT:
|
| 93 |
<section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden">
|
| 94 |
<!-- manual picker -->
|
| 95 |
-
<div class="px-4 pt-4 pb-3 border-b border-brand-100">
|
| 96 |
<label class="block text-[11px] font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Manual</label>
|
| 97 |
<div class="relative">
|
| 98 |
<select x-model="manual" @change="newChat()"
|
|
@@ -106,59 +94,74 @@
|
|
| 106 |
</div>
|
| 107 |
</div>
|
| 108 |
|
| 109 |
-
<!--
|
| 110 |
-
<div
|
| 111 |
-
<
|
| 112 |
-
<
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
</
|
| 118 |
</div>
|
| 119 |
-
|
| 120 |
-
<
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
? 'max-w-[82%] rounded-2xl rounded-br-md bg-brand-600 text-white px-4 py-2.5 text-[.925rem] leading-relaxed shadow-sm ring-1 ring-navy/20'
|
| 137 |
-
: 'max-w-[88%] rounded-2xl rounded-bl-md bg-brand-50 ring-1 ring-brand-200 px-4 py-3 shadow-sm'">
|
| 138 |
-
<div x-show="m.role!=='user'" class="prose-chat" x-html="m.html"></div>
|
| 139 |
-
<span x-show="m.role==='user'" x-text="m.text"></span>
|
| 140 |
-
</div>
|
| 141 |
-
</div>
|
| 142 |
-
</template>
|
| 143 |
-
</div>
|
| 144 |
</template>
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
</
|
| 152 |
</div>
|
| 153 |
-
</div>
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
<div class="flex items-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
<textarea x-model="input" @keydown.enter.prevent="send()" rows="1"
|
| 159 |
:disabled="!ready"
|
| 160 |
-
placeholder="
|
| 161 |
-
class="flex-1 resize-none bg-transparent px-1.5 py-1.5 text-sm text-navy outline-none placeholder:text-brand-400/70 max-h-
|
| 162 |
<button @click="send()" :disabled="loading || !ready || !input.trim()" aria-label="Send"
|
| 163 |
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">
|
| 164 |
<i data-lucide="arrow-up" class="w-5 h-5"></i>
|
|
@@ -218,7 +221,7 @@
|
|
| 218 |
</div>
|
| 219 |
</div>
|
| 220 |
|
| 221 |
-
<!--
|
| 222 |
<template x-if="thumbs.length">
|
| 223 |
<div class="border-t border-brand-100 px-4 py-3">
|
| 224 |
<p class="text-[11px] font-semibold uppercase tracking-wide text-brand-500/80 mb-2">Pages checked</p>
|
|
@@ -288,27 +291,44 @@
|
|
| 288 |
</div>
|
| 289 |
|
| 290 |
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
function repairGuy() {
|
| 292 |
return {
|
| 293 |
client:null, ready:false,
|
| 294 |
manuals:[], manual:'',
|
| 295 |
approaches: __APPROACHES__,
|
| 296 |
approach:'visual', k:__DEFAULT_K__, maxK:__MAX_K__,
|
| 297 |
-
|
| 298 |
settingsOpen:false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
// viewer state machine: which page is open, the manual's chapter index,
|
| 300 |
-
// a jump history for "back", and the active circle overlay.
|
| 301 |
-
// chat history — every request is stateless (grounded in the viewer).
|
| 302 |
viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null,
|
| 303 |
imgW:1240, imgH:1754, // replaced by the real natural size on image load
|
| 304 |
thumbs:[], turnFirstSearch:true,
|
| 305 |
|
| 306 |
get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}` : ''; },
|
| 307 |
|
| 308 |
-
md(t){ return DOMPurify.sanitize(marked.parse(t||'')); },
|
| 309 |
-
|
| 310 |
async init(){
|
| 311 |
-
window.lucide?.createIcons(); // swap <i data-lucide> -> clean SVG icons
|
| 312 |
try{
|
| 313 |
const mod = await import('https://cdn.jsdelivr.net/npm/@gradio/client/+esm');
|
| 314 |
const Client = mod.Client || mod.client;
|
|
@@ -327,13 +347,14 @@ function repairGuy() {
|
|
| 327 |
catch(e){ console.error(e); }
|
| 328 |
},
|
| 329 |
|
| 330 |
-
// re-
|
| 331 |
-
|
|
|
|
|
|
|
| 332 |
|
| 333 |
// switching manuals starts fresh and opens the manual on page 1
|
| 334 |
-
// (viewer-first: the mechanic drives the viewer from word one)
|
| 335 |
async newChat(){
|
| 336 |
-
this.
|
| 337 |
this.viewDoc=''; this.viewPage=1; this.navStack=[]; this.circle=null; this.sections=[];
|
| 338 |
const m = this.manuals.find(x=>x.value===this.manual);
|
| 339 |
if(!m) return;
|
|
@@ -343,29 +364,30 @@ function repairGuy() {
|
|
| 343 |
const r = await fetch(`/sections/${encodeURIComponent(this.manual)}`);
|
| 344 |
this.sections = (await r.json()).sections || [];
|
| 345 |
}catch(e){ console.error(e); }
|
| 346 |
-
this.
|
| 347 |
},
|
| 348 |
|
| 349 |
// --- viewer state machine -----------------------------------------
|
| 350 |
sectionTitle(p){ let t=''; for(const s of this.sections){ if(s.page_start<=p) t=s.title; else break; } return t; },
|
| 351 |
sectionIndex(p){ let i=-1; this.sections.forEach((s,j)=>{ if(s.page_start<=p) i=j; }); return i; },
|
|
|
|
| 352 |
|
| 353 |
setPage(p, push=false){
|
| 354 |
p = Math.max(1, Math.min(this.docPages, p));
|
| 355 |
if(push && this.viewDoc && p!==this.viewPage) this.navStack.push(this.viewPage);
|
| 356 |
if(this.circle && this.circle.page!==p) this.circle=null;
|
| 357 |
this.viewPage = p; this.viewDoc = this.manual;
|
|
|
|
| 358 |
},
|
| 359 |
goRel(d){ this.setPage(this.viewPage+d); },
|
| 360 |
jumpTo(p){ this.setPage(p, true); },
|
| 361 |
goBack(){ const p=this.navStack.pop(); if(p) this.setPage(p); },
|
| 362 |
|
| 363 |
-
|
| 364 |
-
|
|
|
|
| 365 |
|
| 366 |
// --- obvious navigation (handled locally, no server call) ----------
|
| 367 |
-
// Only purely-mechanical viewer commands stay client-side; everything
|
| 368 |
-
// else is a find-and-point request and goes to the GPU router (/find).
|
| 369 |
routeLocal(q){
|
| 370 |
const s = q.toLowerCase().replace(/[.!?]+\s*$/,'').replace(/\s+/g,' ').trim();
|
| 371 |
let m;
|
|
@@ -382,7 +404,7 @@ function repairGuy() {
|
|
| 382 |
}
|
| 383 |
if(/^(where am i|what page is this|what section is this|what section am i in)$/.test(s)){
|
| 384 |
const t = this.sectionTitle(this.viewPage);
|
| 385 |
-
this.say(t ? `You're on
|
| 386 |
return true;
|
| 387 |
}
|
| 388 |
return false;
|
|
@@ -391,13 +413,12 @@ function repairGuy() {
|
|
| 391 |
async send(){
|
| 392 |
const q = this.input.trim();
|
| 393 |
if(!q || this.loading || !this.ready) return;
|
| 394 |
-
this.
|
| 395 |
-
this.input=''; this.scroll();
|
| 396 |
if(!this.manual){ this.say('Pick a manual first ☝️'); return; }
|
| 397 |
if(this.routeLocal(q)) return; // obvious nav — done client-side
|
| 398 |
|
| 399 |
// everything else is a find-and-point turn on the GPU
|
| 400 |
-
this.loading=true; this.status='Working…'; this.turnFirstSearch=true;
|
| 401 |
const find = this.startFindChip();
|
| 402 |
try{
|
| 403 |
const job = this.client.submit('/find', {
|
|
@@ -410,17 +431,17 @@ function repairGuy() {
|
|
| 410 |
for await (const msg of job){
|
| 411 |
if(finished || msg.type!=='data' || !msg.data?.[0]) continue;
|
| 412 |
finished = this.handleFindEvent(msg.data[0], find);
|
| 413 |
-
this.scroll();
|
| 414 |
}
|
| 415 |
}catch(e){ find.icon='circle-slash'; find.label='Something went wrong'; find.detail=String(e); find.pending=false; }
|
| 416 |
-
this.loading=false; this.status='';
|
| 417 |
},
|
| 418 |
|
| 419 |
-
//
|
| 420 |
-
//
|
|
|
|
| 421 |
startFindChip(){
|
| 422 |
-
this.
|
| 423 |
-
return this.
|
| 424 |
},
|
| 425 |
|
| 426 |
// returns true on a terminal event (done or error)
|
|
@@ -432,9 +453,9 @@ function repairGuy() {
|
|
| 432 |
break;
|
| 433 |
|
| 434 |
case 'route':
|
| 435 |
-
if(ev.route==='section'){ find.icon='bookmark'; find.label=`Going to ${ev.title}`; find.detail=`p.${ev.page}`; }
|
| 436 |
-
else if(ev.route==='local'){ find.icon='target'; find.label=`Circling “${ev.target}”`; find.detail='on this page'; this.status=
|
| 437 |
-
else { find.icon='search'; find.label=`Searching: “${ev.query}”`; find.detail=''; this.status=
|
| 438 |
break;
|
| 439 |
|
| 440 |
case 'tool_result': // search candidates → the page strip
|
|
@@ -442,16 +463,18 @@ function repairGuy() {
|
|
| 442 |
if(this.turnFirstSearch){ this.thumbs=[]; this.turnFirstSearch=false; }
|
| 443 |
const seen = new Set(this.thumbs.map(t=>t.page));
|
| 444 |
for(const t of (ev.thumbnails||[])) if(!seen.has(t.page)) this.thumbs.push(t);
|
|
|
|
| 445 |
}
|
| 446 |
break;
|
| 447 |
|
| 448 |
case 'classified': {
|
| 449 |
const yes = (ev.results||[]).filter(r=>r[1]).map(r=>'p.'+r[0]);
|
| 450 |
-
this.status = yes.length ? `Found on ${yes.join(', ')}` : '
|
| 451 |
break;
|
| 452 |
}
|
| 453 |
|
| 454 |
case 'found':
|
|
|
|
| 455 |
this.setPage(ev.page, true); // show the matching page now
|
| 456 |
break;
|
| 457 |
|
|
|
|
| 30 |
|
| 31 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 32 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
|
|
|
| 33 |
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
| 34 |
|
| 35 |
<style>
|
|
|
|
| 40 |
.scroll-thin::-webkit-scrollbar-thumb{background:#b6cbe1;border-radius:9999px}
|
| 41 |
.scroll-thin::-webkit-scrollbar-track{background:transparent}
|
| 42 |
|
| 43 |
+
/* wrench "tightening a bolt" wiggle for the busy indicator */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
@keyframes wrench-turn{0%,100%{transform:rotate(-24deg)}50%{transform:rotate(20deg)}}
|
| 45 |
.animate-wrench{animation:wrench-turn 1s ease-in-out infinite;transform-origin:50% 50%}
|
| 46 |
|
| 47 |
+
/* subtle blueprint cross-hatch behind empty states / the page well */
|
| 48 |
.chat-grid{
|
| 49 |
background-color:#fff;
|
| 50 |
background-image:
|
|
|
|
| 74 |
</button>
|
| 75 |
</header>
|
| 76 |
|
| 77 |
+
<!-- Main: narrow navigator rail + dominant page viewer -->
|
| 78 |
+
<main class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[minmax(290px,340px)_1fr] gap-5 pb-5">
|
| 79 |
|
| 80 |
+
<!-- LEFT: navigator rail — manual picker · table of contents · command bar -->
|
| 81 |
<section class="min-h-0 flex flex-col bg-white rounded-2xl shadow-panel ring-1 ring-navy/10 overflow-hidden">
|
| 82 |
<!-- manual picker -->
|
| 83 |
+
<div class="px-4 pt-4 pb-3 border-b border-brand-100 shrink-0">
|
| 84 |
<label class="block text-[11px] font-semibold uppercase tracking-wide text-brand-500/80 mb-1.5">Manual</label>
|
| 85 |
<div class="relative">
|
| 86 |
<select x-model="manual" @change="newChat()"
|
|
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
|
| 97 |
+
<!-- table of contents (the manual's clean bookmark chapters) -->
|
| 98 |
+
<div class="px-4 pt-3 pb-2 shrink-0 flex items-center justify-between">
|
| 99 |
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-brand-500/80">Contents</span>
|
| 100 |
+
<span x-show="sections.length" class="text-[11px] text-brand-400" x-text="sections.length + ' sections'"></span>
|
| 101 |
+
</div>
|
| 102 |
+
<div x-ref="toc" class="flex-1 min-h-0 overflow-y-auto scroll-thin px-2 pb-2">
|
| 103 |
+
<!-- no manual yet -->
|
| 104 |
+
<div x-show="!viewDoc" class="h-full grid place-items-center text-center px-4">
|
| 105 |
+
<p class="text-sm text-brand-400/90">Pick a manual to see its contents.</p>
|
| 106 |
</div>
|
| 107 |
+
<!-- manual with no bookmarks -->
|
| 108 |
+
<div x-show="viewDoc && !sections.length" class="px-2 py-3">
|
| 109 |
+
<p class="text-sm text-brand-400/90">No table of contents in this PDF — use the page arrows or just ask.</p>
|
| 110 |
+
</div>
|
| 111 |
+
<!-- the chapters -->
|
| 112 |
+
<template x-for="(s,j) in sections" :key="j">
|
| 113 |
+
<button @click="jumpTo(s.page_start)" type="button"
|
| 114 |
+
:data-toc-active="tocActive(j) ? 'true' : 'false'"
|
| 115 |
+
:class="tocActive(j)
|
| 116 |
+
? 'bg-brand-50 ring-1 ring-brand-200 text-navy'
|
| 117 |
+
: 'hover:bg-brand-50/70 text-brand-700'"
|
| 118 |
+
class="group w-full text-left rounded-lg px-2.5 py-2 flex items-start gap-2.5 transition">
|
| 119 |
+
<span class="mt-px text-[11px] tabular-nums font-semibold w-5 shrink-0 text-right"
|
| 120 |
+
:class="tocActive(j) ? 'text-accent-600' : 'text-brand-300'" x-text="j+1"></span>
|
| 121 |
+
<span class="flex-1 min-w-0 text-[13px] leading-snug font-medium" x-text="s.title"></span>
|
| 122 |
+
<span class="text-[11px] tabular-nums text-brand-400 shrink-0 mt-px" x-text="'p.'+s.page_start"></span>
|
| 123 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</template>
|
| 125 |
+
</div>
|
| 126 |
|
| 127 |
+
<!-- command bar: live transcript + the ONE current tool indicator + input -->
|
| 128 |
+
<div class="border-t border-brand-100 p-3 shrink-0 space-y-2.5">
|
| 129 |
+
<!-- last request (what speech-to-text heard, once voice lands) -->
|
| 130 |
+
<div x-show="lastRequest" class="flex items-center gap-2 text-[12px] text-brand-500">
|
| 131 |
+
<i data-lucide="quote" class="w-3.5 h-3.5 text-brand-300 shrink-0"></i>
|
| 132 |
+
<span class="truncate italic" x-text="lastRequest"></span>
|
| 133 |
</div>
|
|
|
|
| 134 |
|
| 135 |
+
<!-- the single status line: animated wrench + live progress while
|
| 136 |
+
working, then the outcome icon + label. ALL progress flows here -->
|
| 137 |
+
<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">
|
| 138 |
+
<!-- working: the blue animated wrench (inline so lucide never swaps
|
| 139 |
+
this Alpine-bound node out — that detaches it from scope) -->
|
| 140 |
+
<svg x-show="activity && activity.pending" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 141 |
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
| 142 |
+
class="w-4 h-4 shrink-0 text-brand-500 animate-wrench">
|
| 143 |
+
<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"/>
|
| 144 |
+
</svg>
|
| 145 |
+
<!-- settled: the outcome icon (x-html so it re-renders on icon change) -->
|
| 146 |
+
<svg x-show="activity && !activity.pending" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 147 |
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
| 148 |
+
class="w-4 h-4 shrink-0 text-accent-600"
|
| 149 |
+
x-html="activity ? (ICONS[activity.icon]||ICONS.info) : ''"></svg>
|
| 150 |
+
<span class="text-sm text-brand-600 min-w-0 flex-1 truncate">
|
| 151 |
+
<span x-text="(activity && activity.pending) ? (status || 'Working…') : (activity?.label || '')"></span>
|
| 152 |
+
<span x-show="activity && !activity.pending && activity.detail" class="text-brand-400" x-text="' · ' + (activity?.detail || '')"></span>
|
| 153 |
+
</span>
|
| 154 |
+
</div>
|
| 155 |
+
<div x-show="!activity" class="text-[12px] text-brand-400/90 px-1 leading-snug">
|
| 156 |
+
Try <em>“go to brake bleeding”</em>, <em>“next page”</em>, <em>“circle the brake hoses”</em>.
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<!-- input (text now; mic + live transcript later) -->
|
| 160 |
+
<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">
|
| 161 |
<textarea x-model="input" @keydown.enter.prevent="send()" rows="1"
|
| 162 |
:disabled="!ready"
|
| 163 |
+
placeholder="Type a request…"
|
| 164 |
+
class="flex-1 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>
|
| 165 |
<button @click="send()" :disabled="loading || !ready || !input.trim()" aria-label="Send"
|
| 166 |
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">
|
| 167 |
<i data-lucide="arrow-up" class="w-5 h-5"></i>
|
|
|
|
| 221 |
</div>
|
| 222 |
</div>
|
| 223 |
|
| 224 |
+
<!-- pages-checked strip (search candidates) -->
|
| 225 |
<template x-if="thumbs.length">
|
| 226 |
<div class="border-t border-brand-100 px-4 py-3">
|
| 227 |
<p class="text-[11px] font-semibold uppercase tracking-wide text-brand-500/80 mb-2">Pages checked</p>
|
|
|
|
| 291 |
</div>
|
| 292 |
|
| 293 |
<script>
|
| 294 |
+
// Inline Lucide paths for the dynamic tool indicator. lucide.createIcons()
|
| 295 |
+
// replaces an <i> with an <svg> ONCE, after which Alpine can't swap its icon
|
| 296 |
+
// on reactive changes — so the evolving chip renders its icon via x-html into
|
| 297 |
+
// a host <svg> instead, which re-renders every time activity.icon changes.
|
| 298 |
+
const ICONS = {
|
| 299 |
+
loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
| 300 |
+
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
| 301 |
+
target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
|
| 302 |
+
bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
|
| 303 |
+
'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"/>',
|
| 304 |
+
'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"/>',
|
| 305 |
+
'circle-slash': '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
| 306 |
+
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
function repairGuy() {
|
| 310 |
return {
|
| 311 |
client:null, ready:false,
|
| 312 |
manuals:[], manual:'',
|
| 313 |
approaches: __APPROACHES__,
|
| 314 |
approach:'visual', k:__DEFAULT_K__, maxK:__MAX_K__,
|
| 315 |
+
input:'', loading:false, status:'',
|
| 316 |
settingsOpen:false,
|
| 317 |
+
ICONS,
|
| 318 |
+
// There is NO chat history. The command bar shows only the last request
|
| 319 |
+
// (the speech-to-text transcript, once voice lands) and `activity`: the
|
| 320 |
+
// single, evolving indicator of the current tool call / one-line reply.
|
| 321 |
+
lastRequest:'', activity:null,
|
| 322 |
// viewer state machine: which page is open, the manual's chapter index,
|
| 323 |
+
// a jump history for "back", and the active circle overlay.
|
|
|
|
| 324 |
viewDoc:'', viewPage:1, docPages:1, sections:[], navStack:[], circle:null,
|
| 325 |
imgW:1240, imgH:1754, // replaced by the real natural size on image load
|
| 326 |
thumbs:[], turnFirstSearch:true,
|
| 327 |
|
| 328 |
get pageUrl(){ return this.viewDoc ? `/page/${encodeURIComponent(this.viewDoc)}/${this.viewPage}` : ''; },
|
| 329 |
|
|
|
|
|
|
|
| 330 |
async init(){
|
| 331 |
+
window.lucide?.createIcons(); // swap static <i data-lucide> -> clean SVG icons
|
| 332 |
try{
|
| 333 |
const mod = await import('https://cdn.jsdelivr.net/npm/@gradio/client/+esm');
|
| 334 |
const Client = mod.Client || mod.client;
|
|
|
|
| 347 |
catch(e){ console.error(e); }
|
| 348 |
},
|
| 349 |
|
| 350 |
+
// re-render the static lucide icons that appear after the first pass
|
| 351 |
+
icons(){ this.$nextTick(()=> window.lucide?.createIcons()); },
|
| 352 |
+
// keep the active chapter visible as the viewer moves
|
| 353 |
+
scrollTocActive(){ this.$nextTick(()=>{ this.$refs.toc?.querySelector('[data-toc-active="true"]')?.scrollIntoView({block:'nearest'}); }); },
|
| 354 |
|
| 355 |
// switching manuals starts fresh and opens the manual on page 1
|
|
|
|
| 356 |
async newChat(){
|
| 357 |
+
this.lastRequest=''; this.activity=null; this.thumbs=[];
|
| 358 |
this.viewDoc=''; this.viewPage=1; this.navStack=[]; this.circle=null; this.sections=[];
|
| 359 |
const m = this.manuals.find(x=>x.value===this.manual);
|
| 360 |
if(!m) return;
|
|
|
|
| 364 |
const r = await fetch(`/sections/${encodeURIComponent(this.manual)}`);
|
| 365 |
this.sections = (await r.json()).sections || [];
|
| 366 |
}catch(e){ console.error(e); }
|
| 367 |
+
this.icons(); this.scrollTocActive();
|
| 368 |
},
|
| 369 |
|
| 370 |
// --- viewer state machine -----------------------------------------
|
| 371 |
sectionTitle(p){ let t=''; for(const s of this.sections){ if(s.page_start<=p) t=s.title; else break; } return t; },
|
| 372 |
sectionIndex(p){ let i=-1; this.sections.forEach((s,j)=>{ if(s.page_start<=p) i=j; }); return i; },
|
| 373 |
+
tocActive(j){ return j===this.sectionIndex(this.viewPage); },
|
| 374 |
|
| 375 |
setPage(p, push=false){
|
| 376 |
p = Math.max(1, Math.min(this.docPages, p));
|
| 377 |
if(push && this.viewDoc && p!==this.viewPage) this.navStack.push(this.viewPage);
|
| 378 |
if(this.circle && this.circle.page!==p) this.circle=null;
|
| 379 |
this.viewPage = p; this.viewDoc = this.manual;
|
| 380 |
+
this.scrollTocActive();
|
| 381 |
},
|
| 382 |
goRel(d){ this.setPage(this.viewPage+d); },
|
| 383 |
jumpTo(p){ this.setPage(p, true); },
|
| 384 |
goBack(){ const p=this.navStack.pop(); if(p) this.setPage(p); },
|
| 385 |
|
| 386 |
+
// the single live indicator: a tool chip, or a one-line spoken-style reply
|
| 387 |
+
chip(icon, label, detail=''){ this.activity={icon, label, detail, pending:false}; this.icons(); },
|
| 388 |
+
say(t){ this.activity={icon:'info', label:t, detail:'', pending:false}; this.icons(); },
|
| 389 |
|
| 390 |
// --- obvious navigation (handled locally, no server call) ----------
|
|
|
|
|
|
|
| 391 |
routeLocal(q){
|
| 392 |
const s = q.toLowerCase().replace(/[.!?]+\s*$/,'').replace(/\s+/g,' ').trim();
|
| 393 |
let m;
|
|
|
|
| 404 |
}
|
| 405 |
if(/^(where am i|what page is this|what section is this|what section am i in)$/.test(s)){
|
| 406 |
const t = this.sectionTitle(this.viewPage);
|
| 407 |
+
this.say(t ? `You're on p. ${this.viewPage} — ${t}.` : `You're on p. ${this.viewPage}.`);
|
| 408 |
return true;
|
| 409 |
}
|
| 410 |
return false;
|
|
|
|
| 413 |
async send(){
|
| 414 |
const q = this.input.trim();
|
| 415 |
if(!q || this.loading || !this.ready) return;
|
| 416 |
+
this.lastRequest = q; this.input='';
|
|
|
|
| 417 |
if(!this.manual){ this.say('Pick a manual first ☝️'); return; }
|
| 418 |
if(this.routeLocal(q)) return; // obvious nav — done client-side
|
| 419 |
|
| 420 |
// everything else is a find-and-point turn on the GPU
|
| 421 |
+
this.loading=true; this.status='Working…'; this.turnFirstSearch=true;
|
| 422 |
const find = this.startFindChip();
|
| 423 |
try{
|
| 424 |
const job = this.client.submit('/find', {
|
|
|
|
| 431 |
for await (const msg of job){
|
| 432 |
if(finished || msg.type!=='data' || !msg.data?.[0]) continue;
|
| 433 |
finished = this.handleFindEvent(msg.data[0], find);
|
|
|
|
| 434 |
}
|
| 435 |
}catch(e){ find.icon='circle-slash'; find.label='Something went wrong'; find.detail=String(e); find.pending=false; }
|
| 436 |
+
this.loading=false; this.status='';
|
| 437 |
},
|
| 438 |
|
| 439 |
+
// seed the single live chip for a find turn; its icon/label evolve as
|
| 440 |
+
// events arrive. `activity` is a reactive property, so mutating the object
|
| 441 |
+
// it points at re-renders.
|
| 442 |
startFindChip(){
|
| 443 |
+
this.activity = {tool:'find', icon:'loader', label:'Working…', detail:'', pending:true};
|
| 444 |
+
return this.activity;
|
| 445 |
},
|
| 446 |
|
| 447 |
// returns true on a terminal event (done or error)
|
|
|
|
| 453 |
break;
|
| 454 |
|
| 455 |
case 'route':
|
| 456 |
+
if(ev.route==='section'){ find.icon='bookmark'; find.label=`Going to ${ev.title}`; find.detail=`p.${ev.page}`; this.status=`Opening ${ev.title}…`; }
|
| 457 |
+
else if(ev.route==='local'){ find.icon='target'; find.label=`Circling “${ev.target}”`; find.detail='on this page'; this.status=`Looking for “${ev.target}” on this page…`; }
|
| 458 |
+
else { find.icon='search'; find.label=`Searching: “${ev.query}”`; find.detail=''; this.status=`Searching the manual for “${ev.query}”…`; }
|
| 459 |
break;
|
| 460 |
|
| 461 |
case 'tool_result': // search candidates → the page strip
|
|
|
|
| 463 |
if(this.turnFirstSearch){ this.thumbs=[]; this.turnFirstSearch=false; }
|
| 464 |
const seen = new Set(this.thumbs.map(t=>t.page));
|
| 465 |
for(const t of (ev.thumbnails||[])) if(!seen.has(t.page)) this.thumbs.push(t);
|
| 466 |
+
this.status = `Reading ${this.thumbs.length} candidate page${this.thumbs.length===1?'':'s'}…`;
|
| 467 |
}
|
| 468 |
break;
|
| 469 |
|
| 470 |
case 'classified': {
|
| 471 |
const yes = (ev.results||[]).filter(r=>r[1]).map(r=>'p.'+r[0]);
|
| 472 |
+
this.status = yes.length ? `Found on ${yes.join(', ')}` : 'Not on those pages…';
|
| 473 |
break;
|
| 474 |
}
|
| 475 |
|
| 476 |
case 'found':
|
| 477 |
+
this.status = `Showing p.${ev.page}…`;
|
| 478 |
this.setPage(ev.page, true); // show the matching page now
|
| 479 |
break;
|
| 480 |
|
models/minicpm.py
CHANGED
|
@@ -79,10 +79,17 @@ ROUTER_PROMPT = (
|
|
| 79 |
)
|
| 80 |
|
| 81 |
# Parallel page classification: one batched call, one YES/NO per candidate.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
CLASSIFY_PROMPT = (
|
| 83 |
-
"The image is one page of a repair manual.
|
| 84 |
-
"
|
| 85 |
-
"
|
|
|
|
|
|
|
|
|
|
| 86 |
)
|
| 87 |
|
| 88 |
# Visual grounding for "circle the <thing>": the mechanic asks to circle
|
|
|
|
| 79 |
)
|
| 80 |
|
| 81 |
# Parallel page classification: one batched call, one YES/NO per candidate.
|
| 82 |
+
# Relevance-by-topic, NOT literal containment — the page that helps with a
|
| 83 |
+
# "radiator leak" is the radiator page, which never literally shows a leak.
|
| 84 |
+
# Too strict a test gives up on good retrievals; this still rejects pages about
|
| 85 |
+
# an unrelated system (and truly absent things, so the give-up path survives).
|
| 86 |
CLASSIFY_PROMPT = (
|
| 87 |
+
"The image is one page of a repair manual. A mechanic is looking for "
|
| 88 |
+
"{target!r}. Is THIS page relevant to that — does it show or cover the "
|
| 89 |
+
"component, system, or procedure involved (as a diagram, table, "
|
| 90 |
+
"specification, or steps)? Judge by topic, not exact wording: a page "
|
| 91 |
+
"about the radiator is relevant to a 'radiator leak'; a page about an "
|
| 92 |
+
"unrelated system is not. Reply with ONLY YES or NO."
|
| 93 |
)
|
| 94 |
|
| 95 |
# Visual grounding for "circle the <thing>": the mechanic asks to circle
|