airayven7 commited on
Commit
488dd26
·
verified ·
1 Parent(s): 89111aa

Sync from GitHub f01dcd2

Browse files
Files changed (2) hide show
  1. frontend/index.html +117 -94
  2. 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
- /* assistant markdown */
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 the chat (scrolls with the messages) */
60
  .chat-grid{
61
  background-color:#fff;
62
  background-image:
@@ -86,13 +74,13 @@
86
  </button>
87
  </header>
88
 
89
- <!-- Main two-panel layout -->
90
- <main class="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-5 pb-5">
91
 
92
- <!-- LEFT: chat -->
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
- <!-- messages -->
110
- <div x-ref="chat" class="chat-grid flex-1 min-h-0 overflow-y-auto scroll-thin px-4 py-5 space-y-4">
111
- <!-- empty state -->
112
- <div x-show="messages.length===0" class="h-full grid place-items-center text-center px-6">
113
- <div>
114
- <i data-lucide="wrench" class="inline-block w-11 h-11 text-brand-300 mb-3"></i>
115
- <p class="font-semibold text-navy">Your hands-busy manual assistant</p>
116
- <p class="text-sm text-brand-500/80 mt-1 max-w-xs mx-auto">Pick a manual above, then drive the viewer: <em>“go to brake bleeding”</em>, <em>“next page”</em>, <em>“circle the brake hoses”</em>. I find the right page and point at it.</p>
117
- </div>
118
  </div>
119
-
120
- <template x-for="(m,i) in messages" :key="i">
121
- <div>
122
- <!-- tool-call chip -->
123
- <template x-if="m.role==='tool'">
124
- <div class="flex justify-start">
125
- <div class="flex items-center gap-2 rounded-full bg-white ring-1 ring-brand-200 pl-2.5 pr-3.5 py-1.5 text-[12px] text-brand-600 shadow-sm">
126
- <i :data-lucide="m.icon" class="w-3.5 h-3.5 text-accent-600 shrink-0"></i>
127
- <span class="font-medium" x-text="m.label"></span>
128
- <span class="text-brand-400" x-show="m.detail" x-text="m.detail"></span>
129
- </div>
130
- </div>
131
- </template>
132
- <!-- chat bubble -->
133
- <template x-if="m.role!=='tool'">
134
- <div :class="m.role==='user' ? 'flex justify-end' : 'flex justify-start'">
135
- <div :class="m.role==='user'
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
- <!-- thinking / live status -->
147
- <div x-show="loading" class="flex justify-start">
148
- <div class="rounded-2xl rounded-bl-md bg-brand-50 ring-1 ring-brand-200 px-4 py-3 flex items-center gap-2">
149
- <i data-lucide="wrench" class="w-4 h-4 animate-wrench text-brand-500"></i>
150
- <span class="text-sm text-brand-600" x-text="status || 'Thinking…'"></span>
151
- </div>
152
  </div>
153
- </div>
154
 
155
- <!-- input -->
156
- <div class="border-t border-brand-100 p-3">
157
- <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-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  <textarea x-model="input" @keydown.enter.prevent="send()" rows="1"
159
  :disabled="!ready"
160
- placeholder="“go to the brakes section”, “next page”, “circle the brake hoses”…"
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-32"></textarea>
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
- <!-- cited pages strip -->
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
- messages:[], input:'', loading:false, status:'',
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. There is no
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-run lucide for chips appended after the initial createIcons pass
331
- scroll(){ this.$nextTick(()=>{ window.lucide?.createIcons(); const el=this.$refs.chat; if(el) el.scrollTop = el.scrollHeight; }); },
 
 
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.messages=[]; this.thumbs=[];
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.scroll();
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
- chip(icon, label, detail=''){ this.messages.push({role:'tool', tool:'local', icon, label, detail, pending:false}); this.scroll(); },
364
- say(t){ this.messages.push({role:'assistant', html:this.md(t)}); this.scroll(); },
 
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 **p. ${this.viewPage}***${t}*.` : `You're on **p. ${this.viewPage}**.`);
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.messages.push({role:'user', text:q});
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; this.scroll();
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=''; this.scroll();
417
  },
418
 
419
- // one chip per find turn; its label/icon evolves as events arrive (router
420
- // decision → outcome). Returns the reactive proxy so mutations re-render.
 
421
  startFindChip(){
422
- this.messages.push({role:'tool', tool:'find', icon:'loader', label:'Working…', detail:'', pending:true});
423
- return this.messages[this.messages.length-1];
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='Pinning it down'; }
437
- else { find.icon='search'; find.label=`Searching: “${ev.query}”`; find.detail=''; this.status='Searching the manual…'; }
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(', ')}` : 'Checking pages…';
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. Does this page contain or "
84
- "show {target!r} (as a diagram part, a table entry, a specification, or "
85
- "a procedure)? Reply with ONLY YES or NO."
 
 
 
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