Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>HTR · VLM as Annotator</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%234f46e5'/%3E%3Ctext x='16' y='22' text-anchor='middle' fill='white' font-family='system-ui' font-weight='700' font-size='18'%3EH%3C/text%3E%3C/svg%3E" /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| <script src="/static/tailwind.min.js"></script> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| fontFamily: { | |
| sans: ['Inter', 'system-ui', 'sans-serif'], | |
| mono: ['JetBrains Mono', 'ui-monospace', 'monospace'], | |
| }, | |
| colors: { | |
| ink: { | |
| 950: '#0b0f19', 900: '#111827', 800: '#1f2937', 700: '#374151', | |
| 500: '#6b7280', 400: '#9ca3af', 300: '#d1d5db', 200: '#e5e7eb', | |
| 100: '#f3f4f6', 50: '#f9fafb', | |
| }, | |
| accent: { | |
| 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', | |
| 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', | |
| }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| </script> | |
| <link rel="stylesheet" href="/static/styles.css?v=12" /> | |
| <!-- app.js must load BEFORE alpine.min.js --> | |
| <script defer src="/static/app.js?v=29"></script> | |
| <script defer src="/static/alpine.min.js"></script> | |
| </head> | |
| <body class="font-sans bg-ink-50 text-ink-900 min-h-screen" x-data="htrApp()" x-init="init()"> | |
| <noscript> | |
| <div class="p-6 max-w-xl mx-auto mt-10 bg-yellow-100 text-yellow-800 rounded-lg">This application requires JavaScript to be enabled.</div> | |
| </noscript> | |
| <!-- ===================================================================== | |
| HEADER | |
| ===================================================================== --> | |
| <header class="sticky top-0 z-40 bg-white/95 backdrop-blur border-b border-ink-200"> | |
| <div class="max-w-[1400px] mx-auto px-5 py-3 flex items-center gap-3 flex-wrap"> | |
| <div class="flex items-center gap-2.5"> | |
| <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-accent-700 flex items-center justify-center text-white font-bold shadow-sm">H</div> | |
| <div> | |
| <h1 class="font-semibold text-ink-900 leading-tight text-[15px]">HTR · VLM as Annotator</h1> | |
| <div class="text-[11px] text-ink-500 leading-tight">LLM as annotator · OpenRouter</div> | |
| </div> | |
| </div> | |
| <span class="text-ink-300 mx-1">·</span> | |
| <!-- Mode toggle --> | |
| <div class="inline-flex bg-ink-100 rounded-lg p-0.5 border border-ink-200"> | |
| <button @click="setMode('ocr')" | |
| :class="state.mode==='ocr' ? 'bg-white shadow-sm text-ink-900' : 'text-ink-500 hover:text-ink-900'" | |
| class="px-3 py-1 text-xs rounded-md font-medium transition-colors">OCR</button> | |
| <button @click="setMode('post_correction')" | |
| :class="state.mode==='post_correction' ? 'bg-white shadow-sm text-ink-900' : 'text-ink-500 hover:text-ink-900'" | |
| class="px-3 py-1 text-xs rounded-md font-medium transition-colors">Post-correction</button> | |
| </div> | |
| <!-- Settings pill (opens with the tab matching current mode) --> | |
| <button @click="openSettings('general')" class="pill" :title="`Lang: ${state.settings.language || '—'}`"> | |
| <span class="pill-icon">⚙</span> | |
| <span class="pill-label">Settings</span> | |
| <span class="pill-value truncate max-w-[140px]" x-text="state.settings.language || 'configure'"></span> | |
| </button> | |
| <!-- Models pill --> | |
| <button @click="openSettings(state.mode === 'post_correction' ? 'moe' : 'ocr')" class="pill" | |
| :class="state.mode === 'post_correction' ? 'pill-moe' : ''" | |
| :title="state.mode==='ocr' ? state.settings.ocr_model : `A: ${state.settings.moe_expert_a} · B: ${state.settings.moe_expert_b} · Judge: ${state.settings.moe_judge}`"> | |
| <span class="pill-icon" x-text="state.mode==='post_correction' ? '🧠' : '🤖'"></span> | |
| <span class="pill-label" x-text="state.mode==='post_correction' ? 'MoE' : 'Model'"></span> | |
| <span class="pill-value font-mono text-[11px]" | |
| x-text="state.mode==='post_correction' ? '3 models' : (state.settings.ocr_model || '—').split('/').slice(-1)[0]"></span> | |
| </button> | |
| <!-- API key --> | |
| <button @click="modal='key'" class="pill" | |
| :class="apiKey ? 'pill-ok' : (state.env_key_present ? '' : 'pill-warn')"> | |
| <span class="pill-icon" x-text="apiKey ? '🔑' : '⚠'"></span> | |
| <span class="pill-label">API key</span> | |
| <span class="pill-value" x-text="apiKey ? 'set' : (state.env_key_present ? 'env' : 'missing')"></span> | |
| </button> | |
| <!-- ICL --> | |
| <button @click="modal='icl'" class="pill" :class="visibleIclPool.length > 0 ? 'pill-ok' : ''" | |
| :title="`${visibleIclPool.length} example(s) usable for the active page · ${state.icl_pool.length} total in pool`"> | |
| <span class="pill-icon">📚</span> | |
| <span class="pill-label">ICL</span> | |
| <span class="pill-value" x-text="visibleIclPool.length + ' ex'"></span> | |
| </button> | |
| <!-- Cite --> | |
| <button @click="modal='cite'" class="pill" title="Cite this work"> | |
| <span class="pill-label">Cite this app</span> | |
| </button> | |
| <!-- Cost pill (OpenRouter usage accounting) --> | |
| <button @click="modal='cost'" class="pill" | |
| :class="state.totals?.cost_usd > 0 ? 'pill-ok' : ''" | |
| :title="`${state.totals?.requests || 0} request(s) · ${state.totals?.input_tokens || 0} in + ${state.totals?.output_tokens || 0} out tokens`"> | |
| <!-- <span class="pill-icon">💲</span> --> | |
| <span class="pill-label">Cost</span> | |
| <span class="pill-value font-mono tabular-nums" x-text="formatCost(state.totals?.cost_usd)"></span> | |
| </button> | |
| <div class="flex-1"></div> | |
| <!-- <span class="text-[11px] text-ink-400 hidden md:inline" x-text="state.pages.length + ' page(s)'"></span> --> | |
| </div> | |
| </header> | |
| <!-- Sandbox metadata band — visible only when the active page is a sandbox sample. --> | |
| <div x-show="activePage && activePage.is_sandbox" x-cloak | |
| class="border-b border-accent-200 bg-gradient-to-r from-accent-50 to-white"> | |
| <div class="max-w-[1400px] mx-auto px-5 py-2 flex items-center gap-3 text-xs flex-wrap"> | |
| <span class="text-[10px] uppercase tracking-wider font-semibold text-accent-600">sandbox</span> | |
| <span class="font-medium text-ink-800" x-text="activePage?.title"></span> | |
| <span class="text-ink-300">·</span> | |
| <span class="text-ink-600">lang <span class="font-medium text-ink-800" x-text="activePage?.language"></span></span> | |
| <template x-if="activePage?.citation_url"> | |
| <a :href="activePage.citation_url" target="_blank" rel="noopener" | |
| class="ml-auto inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-accent-500 text-accent-700 hover:bg-accent-500 hover:text-white font-medium transition-colors"> | |
| ↗ source dataset | |
| </a> | |
| </template> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| MAIN LAYOUT | |
| ===================================================================== --> | |
| <main class="max-w-[1400px] mx-auto px-5 py-4"> | |
| <div class="grid grid-cols-12 gap-4"> | |
| <!-- ── Sidebar ──────────────────────────────────────────────────── --> | |
| <aside class="col-span-12 md:col-span-3 lg:col-span-2"> | |
| <!-- About / quick intro --> | |
| <div class="card !p-3 border-accent-200 bg-accent-50/40"> | |
| <div class="card-title !mb-1.5">About</div> | |
| <p class="text-[11px] text-ink-700 leading-relaxed"> | |
| HTR models need a lot of training data — a major bottleneck for under-resourced | |
| languages and historical scripts. This app bootstraps that data through a | |
| <strong>"VLM-as-annotator"</strong> loop: the model proposes, you validate. | |
| </p> | |
| <p class="text-[11px] text-ink-700 leading-relaxed mt-2"> | |
| Transcribe handwritten or printed pages with a single VLM, or run a | |
| <strong>Mixture-of-Experts</strong> post-correction (two experts + judge). | |
| Output as plain text or any custom JSON schema. The defaults work on a wide | |
| variety of documents. | |
| </p> | |
| <p class="text-[11px] text-ink-700 leading-relaxed mt-2 pt-2 border-t border-accent-200"> | |
| <strong>In-Context Learning</strong> is on by default: every transcription you | |
| validate joins a pool of examples that gets injected as few-shot demonstrations | |
| on subsequent pages, so accuracy improves as you go. | |
| </p> | |
| <p class="text-[11px] text-ink-700 leading-relaxed mt-2"> | |
| ⚠ An OpenRouter | |
| <button @click="modal='key'" class="underline font-medium text-accent-700 hover:text-accent-600">API key</button> | |
| with credits is required. We default to <code class="bg-white/60 px-1 rounded">google/gemini-3-flash-preview</code>, | |
| but any OpenRouter model slug works — paste it in <em>Settings</em>. | |
| </p> | |
| <p class="text-[11px] text-ink-700 leading-relaxed mt-2 pt-2 border-t border-accent-200"> | |
| Working on plain text or linguistic annotation instead? | |
| Try our sister app | |
| <a href="https://huggingface.co/spaces/ENC-PSL/lrec2026-llm-annotator" target="_blank" rel="noopener" | |
| class="underline font-medium text-accent-700 hover:text-accent-600">LLM-as-annotator for text & linguistics</a>. | |
| </p> | |
| </div> | |
| <div class="card mt-3 !p-3"> | |
| <input type="file" multiple accept="image/*" class="sr-only" x-ref="fileInput" | |
| @change="uploadFiles($event.target.files); $event.target.value=''" /> | |
| <button @click="$refs.fileInput?.click()" class="btn btn-primary w-full"> | |
| <span>↑</span> Upload images | |
| </button> | |
| <p class="text-[10px] text-ink-400 mt-1.5 text-center">JPG · PNG · WebP</p> | |
| </div> | |
| <div class="card mt-3 !p-2 max-h-[68vh] overflow-y-auto"> | |
| <div class="card-title px-1 !mb-2">Pages</div> | |
| <ul class="space-y-1"> | |
| <template x-for="(p, idx) in state.pages" :key="p.id"> | |
| <li> | |
| <button @click="selectPage(idx)" | |
| :class="idx === activePageIdx ? 'border-accent-500 bg-accent-50 ring-2 ring-accent-200' : 'border-ink-200 bg-white hover:border-ink-300 hover:bg-ink-50'" | |
| class="w-full text-left border rounded-lg p-1.5 flex items-center gap-2 transition-all group"> | |
| <span class="text-[10px] font-mono text-ink-400 w-4 text-right" x-text="idx + 1"></span> | |
| <img :src="thumbDataUrl(p.image_id)" class="w-10 h-10 object-cover rounded bg-ink-100 border border-ink-200 flex-shrink-0" alt="" loading="lazy" /> | |
| <div class="flex-1 min-w-0"> | |
| <div class="text-xs font-medium truncate" x-text="p.is_sandbox ? (p.short_label || 'sandbox') : (state.images[p.image_id]?.filename || '(no name)')"></div> | |
| <div class="text-[10px] flex items-center gap-1"> | |
| <span class="dot" :class="dotClass(p.status)"></span> | |
| <span class="text-ink-500" x-text="statusLabel(p.status)"></span> | |
| <span x-show="p.is_sandbox" class="ml-auto text-[9px] uppercase tracking-wide text-accent-600 font-semibold">sandbox</span> | |
| </div> | |
| </div> | |
| <button @click.stop="deletePage(idx)" class="opacity-0 group-hover:opacity-100 transition-opacity text-ink-300 hover:text-red-600 text-sm w-5 h-5 grid place-items-center" title="Delete">✕</button> | |
| </button> | |
| </li> | |
| </template> | |
| <li x-show="state.pages.length === 0" class="text-[11px] text-ink-400 p-3 text-center leading-relaxed"> | |
| No images yet.<br/> | |
| <span class="text-ink-300">Upload or try the sandbox.</span> | |
| </li> | |
| </ul> | |
| </div> | |
| <!-- Export all pages --> | |
| <div class="card mt-3 !p-2" x-show="state.pages.length > 0"> | |
| <div class="card-title px-1 !mb-2">Export all</div> | |
| <div class="flex flex-col gap-1.5 px-1"> | |
| <a :href="`/api/pages/export?fmt=jsonl`" download class="btn btn-secondary text-xs justify-start">⇩ All pages · JSONL</a> | |
| <a :href="`/api/pages/export?fmt=txt`" download class="btn btn-secondary text-xs justify-start">⇩ All pages · TXT</a> | |
| </div> | |
| </div> | |
| <!-- Reset session --> | |
| <button @click="resetSession()" class="btn btn-ghost w-full mt-3 text-xs text-ink-500 hover:text-red-600" | |
| title="Clear all pages, ICL pool, cost totals, language, models, prompts, JSON template, guidelines and custom overrides. Sandbox samples are reloaded. API key (stored locally) is kept."> | |
| ⟳ Reset session | |
| </button> | |
| </aside> | |
| <!-- ── Center: image ────────────────────────────────────────────── --> | |
| <section class="col-span-12 md:col-span-5 lg:col-span-5"> | |
| <div class="card !p-0 relative overflow-hidden" style="height: calc(100vh - 110px); min-height: 480px"> | |
| <template x-if="activePage && activeImageDataUrl"> | |
| <div class="h-full overflow-auto bg-ink-50" | |
| x-ref="imgScroll" | |
| :class="annotateMode ? 'cursor-crosshair' : (panning ? 'cursor-grabbing' : (zoom > 1 ? 'cursor-grab' : ''))" | |
| @mousedown="startPan($event)" | |
| @mousemove="onPan($event)" | |
| @mouseup.window="endPan()" | |
| @mouseleave="endPan()"> | |
| <!-- Wrapper that grows with the scaled image so the scroll container has real | |
| space to scroll. At zoom > 1 we add one viewport of slack around the image | |
| so any edge can be panned to the centre of the view. --> | |
| <div class="flex items-center justify-center" | |
| :style="imgFitW | |
| ? `width: max(100%, ${imgFitW * zoom + (zoom > 1 ? containerW : 0)}px); height: max(100%, ${imgFitH * zoom + (zoom > 1 ? containerH : 0)}px);` | |
| : 'width:100%; height:100%;'"> | |
| <div class="relative flex-shrink-0" | |
| :style="imgFitW ? `width:${imgFitW * zoom}px; height:${imgFitH * zoom}px;` : ''"> | |
| <img :src="activeImageDataUrl" alt="page" draggable="false" | |
| @load="onImgLoad($event)" | |
| @click="annotateClick($event)" | |
| class="rounded shadow-md select-none block w-full h-full" | |
| :style="imgFitW ? '' : 'max-width:100%; max-height:100%;'" /> | |
| <!-- Detection bbox overlay — plain HTML divs positioned as | |
| percentages of the natural image dimensions. They live | |
| inside the same relative wrapper as the <img>, so they | |
| scale with zoom for free. No SVG, no namespace issues. --> | |
| <div x-show="showDetections" x-cloak | |
| class="absolute inset-0 pointer-events-none"> | |
| <template x-for="d in (activePage?.detections || [])" :key="d.id"> | |
| <div class="absolute box-border" | |
| :style="bboxStyle(d)"> | |
| <div class="absolute left-0 bottom-full px-1 py-0.5 text-[10px] font-semibold text-white whitespace-nowrap leading-none" | |
| :style="`background:${d.color}; border-top-left-radius:2px; border-top-right-radius:2px;`" | |
| x-text="d.label"></div> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Zoom controls --> | |
| <div x-show="activePage && activeImageDataUrl" x-cloak | |
| class="absolute bottom-3 right-3 z-10 flex items-center gap-1 bg-white/95 border border-ink-200 rounded-lg shadow-md px-1 py-0.5"> | |
| <button @click="zoom = Math.max(0.25, zoom - 0.25)" class="w-7 h-7 grid place-items-center text-ink-600 hover:bg-ink-100 rounded" title="Zoom out">−</button> | |
| <span class="text-[11px] font-mono tabular-nums text-ink-600 w-10 text-center" x-text="Math.round(zoom * 100) + '%'"></span> | |
| <button @click="zoom = Math.min(6, zoom + 0.25)" class="w-7 h-7 grid place-items-center text-ink-600 hover:bg-ink-100 rounded" title="Zoom in">+</button> | |
| <button @click="zoom = 1" class="ml-0.5 px-2 h-7 text-[11px] text-ink-500 hover:bg-ink-100 rounded" title="Reset zoom">reset</button> | |
| </div> | |
| <div x-show="!activePage" class="h-full flex flex-col items-center justify-center text-ink-400 p-12 text-center"> | |
| <div class="text-5xl mb-3 opacity-30">🖼</div> | |
| <p class="text-sm font-medium text-ink-600">No page selected</p> | |
| <p class="text-xs text-ink-400 mt-1 leading-relaxed">Use Upload or pick a sandbox in the sidebar.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ── Right pane ───────────────────────────────────────────────── --> | |
| <section class="col-span-12 md:col-span-4 lg:col-span-5"> | |
| <div class="card !p-0 flex flex-col" style="height: calc(100vh - 110px); min-height: 480px"> | |
| <!-- OCR mode --> | |
| <template x-if="state.mode === 'ocr'"> | |
| <div class="flex flex-col h-full"> | |
| <div class="px-4 py-3 border-b border-ink-200 flex items-center gap-2 flex-wrap"> | |
| <button @click="runOcr()" :disabled="!activePage || busy" class="btn btn-primary"> | |
| <template x-if="!busy"><span class="inline-flex items-center gap-1.5">▶ Run OCR</span></template> | |
| <template x-if="busy"><span class="inline-flex items-center gap-2"><span class="spinner"></span>Running…</span></template> | |
| </button> | |
| <button @click="previewOcrPrompt()" :disabled="!activePage" class="btn btn-secondary" title="See exactly what will be sent to the model (including injected ICL examples)"> | |
| Preview prompt | |
| </button> | |
| <button @click="validateAndScore()" :disabled="!activePage || !editorText" class="btn btn-secondary"> | |
| ✓ Validate & score | |
| </button> | |
| <button @click="addCurrentToIcl()" :disabled="!activePage || !editorText" class="btn btn-secondary" title="Validate, score, add to ICL pool"> | |
| Add ICL | |
| </button> | |
| <div class="ml-auto inline-flex items-center gap-1.5"> | |
| <a :href="activePage ? `/api/page/${activePageIdx}/export?fmt=txt` : '#'" :class="!activePage ? 'pointer-events-none opacity-40' : ''" download class="btn btn-ghost text-xs">⇩ TXT</a> | |
| <a :href="activePage ? `/api/page/${activePageIdx}/export?fmt=json` : '#'" :class="!activePage ? 'pointer-events-none opacity-40' : ''" download class="btn btn-ghost text-xs">⇩ JSON</a> | |
| <button @click="sendToPostCorr()" :disabled="!activePage || !editorText" class="btn btn-ghost text-xs">→ Post-corr</button> | |
| </div> | |
| </div> | |
| <!-- ICL injection indicator --> | |
| <div class="px-4 py-1.5 border-b border-ink-200 text-[11px] text-ink-500 bg-ink-50/40 flex items-center gap-2 flex-wrap" | |
| x-show="state.settings.use_icl"> | |
| <span class="font-medium">ICL few-shot:</span> | |
| <span x-show="state.icl_pool.length === 0" class="text-ink-400">pool empty — no examples to inject</span> | |
| <span x-show="state.icl_pool.length > 0"> | |
| up to <strong x-text="state.settings.n_icl"></strong> example(s) from the pool of | |
| <strong x-text="state.icl_pool.length"></strong> | |
| <span x-show="state.settings.language" x-text="`(filtered by language: ${state.settings.language})`"></span> | |
| will be injected as image + transcription pairs <em>before</em> the target page. | |
| </span> | |
| <button @click="previewOcrPrompt()" x-show="activePage" class="text-accent-600 hover:underline ml-auto">see the actual prompt →</button> | |
| </div> | |
| <div class="px-4 py-1.5 border-b border-ink-200 text-[11px] text-ink-400 bg-ink-50/40" x-show="!state.settings.use_icl"> | |
| <span class="font-medium">ICL few-shot:</span> disabled in Settings → OCR tab. | |
| </div> | |
| <div class="px-4 py-2 border-b border-ink-200 flex items-center gap-3 text-xs bg-ink-50/60" x-show="activePage"> | |
| <span class="text-ink-500 font-mono">page <span class="font-semibold text-ink-700" x-text="activePageIdx + 1"></span> / <span x-text="state.pages.length"></span></span> | |
| <span class="text-ink-300">·</span> | |
| <span class="flex items-center gap-1.5"> | |
| <span class="dot" :class="dotClass(activePage?.status)"></span> | |
| <span class="font-medium text-ink-600" x-text="statusLabel(activePage?.status)"></span> | |
| </span> | |
| <span class="ml-auto flex items-center gap-2 flex-wrap"> | |
| <template x-if="activePage?.usage"> | |
| <span class="accuracy-pill" :title="`${activePage.usage.prompt_tokens} in + ${activePage.usage.completion_tokens} out tokens`" | |
| x-text="formatCost(activePage.usage.cost) + ' · ' + (activePage.usage.total_tokens || '?') + ' tok'"></span> | |
| </template> | |
| <template x-if="activePage && activePage.cer !== null && activePage.cer !== undefined"> | |
| <span class="inline-flex gap-2"> | |
| <span class="accuracy-pill" :class="metricClass(activePage.cer)" x-text="`CER ${(activePage.cer*100).toFixed(2)}%`"></span> | |
| <span class="accuracy-pill" :class="metricClass(activePage.wer)" x-text="`WER ${(activePage.wer*100).toFixed(2)}%`"></span> | |
| </span> | |
| </template> | |
| </span> | |
| </div> | |
| <details class="border-b border-ink-200 bg-ink-50/60" x-bind:open="annotateMode"> | |
| <summary class="px-4 py-2 text-xs text-ink-600 cursor-pointer hover:bg-ink-100 select-none font-medium flex items-center gap-2"> | |
| <span>Annotate objects (FastSAM click-to-segment)</span> | |
| <span x-show="(activePage?.detections || []).length > 0" | |
| class="badge badge-corrected" | |
| x-text="(activePage?.detections || []).length + ' bbox'"></span> | |
| <span x-show="annotateMode" class="badge badge-uploaded">annotate ON</span> | |
| </summary> | |
| <div class="px-4 py-3 flex flex-col gap-2"> | |
| <div class="flex gap-2 items-center flex-wrap"> | |
| <button @click="annotateMode = !annotateMode" :disabled="!activePage" | |
| :class="annotateMode ? 'btn-primary' : 'btn-secondary'" | |
| class="btn text-xs"> | |
| <span x-text="annotateMode ? '⏹ Stop annotating' : 'Start annotating'"></span> | |
| </button> | |
| <input x-model="annotateDefaultLabel" type="text" | |
| class="input text-xs max-w-[180px]" | |
| placeholder="default label (e.g. undefined)" /> | |
| <label x-show="(activePage?.detections || []).length > 0" class="text-[11px] text-ink-600 inline-flex items-center gap-1.5 ml-auto"> | |
| <input type="checkbox" x-model="showDetections" /> | |
| Show on image | |
| </label> | |
| </div> | |
| <p class="text-[10px] text-ink-500"> | |
| When ON, click on an object in the image to add a bounding box (FastSAM segments the region under the click). | |
| Each bbox is editable: rename or delete below. Pan/zoom are paused while annotating. | |
| </p> | |
| <p x-show="samBusy" x-cloak class="text-[11px] text-accent-600 inline-flex items-center gap-1.5"> | |
| <span class="spinner"></span> Segmenting… | |
| </p> | |
| <ul x-show="(activePage?.detections || []).length > 0" class="space-y-1.5 mt-1"> | |
| <template x-for="d in (activePage?.detections || [])" :key="d.id"> | |
| <li class="flex items-center gap-2 text-[11px] bg-white border border-ink-200 rounded-md px-2 py-1.5"> | |
| <span class="inline-block w-3 h-3 rounded-sm border border-ink-300 flex-shrink-0" :style="`background:${d.color}`"></span> | |
| <input type="text" :value="d.label" | |
| @change="relabelAnnotation(d.id, $event.target.value)" | |
| class="bg-transparent border-b border-transparent focus:border-accent-500 outline-none px-0 text-ink-800 font-medium min-w-[80px] max-w-[160px]" /> | |
| <span class="text-ink-400 font-mono" x-text="`[${d.bbox_px.join(',')}]`"></span> | |
| <button @click="deleteAnnotation(d.id)" class="ml-auto text-ink-300 hover:text-red-600 w-5 h-5 grid place-items-center flex-shrink-0" title="Delete">✕</button> | |
| </li> | |
| </template> | |
| </ul> | |
| </div> | |
| </details> | |
| <details class="border-b border-ink-200 bg-ink-50/60"> | |
| <summary class="px-4 py-2 text-xs text-ink-600 cursor-pointer hover:bg-ink-100 select-none font-medium"> | |
| Quick add to ICL (paste a validated transcription) | |
| </summary> | |
| <div class="px-4 py-3 flex flex-col gap-2"> | |
| <div class="inline-flex bg-white rounded-md p-0.5 border border-ink-200 self-start text-xs"> | |
| <button @click="quickIclMode='text'" | |
| :class="quickIclMode==='text' ? 'bg-accent-500 text-white' : 'text-ink-500 hover:bg-ink-50'" | |
| class="px-2.5 py-0.5 rounded-sm font-medium transition-colors">Plain text</button> | |
| <button @click="quickIclMode='json'" | |
| :class="quickIclMode==='json' ? 'bg-accent-500 text-white' : 'text-ink-500 hover:bg-ink-50'" | |
| class="px-2.5 py-0.5 rounded-sm font-medium transition-colors">JSON</button> | |
| </div> | |
| <textarea x-model="quickIclText" rows="4" | |
| :class="quickIclMode==='json' ? 'font-mono text-[11px]' : ''" | |
| class="input" | |
| :placeholder="quickIclMode==='json' ? 'Paste the JSON object the model should learn to produce…' : 'Paste validated transcription for the active image…'"></textarea> | |
| <div class="flex items-center gap-2"> | |
| <button @click="quickAddIcl()" :disabled="!activePage || !quickIclText" class="btn btn-primary text-xs">Add to ICL</button> | |
| <span x-show="quickIclMode==='json' && quickIclJsonError" x-cloak class="text-[11px] text-red-600" x-text="quickIclJsonError"></span> | |
| </div> | |
| </div> | |
| </details> | |
| <div class="flex-1 overflow-y-auto p-4" x-show="activePage"> | |
| <template x-if="activePage?.diff && activePage.diff.length > 0 && !editing"> | |
| <div> | |
| <div class="flex items-center justify-between mb-2"> | |
| <span class="lbl !mb-0">Validated · edits in green</span> | |
| <button @click="editing = true" class="text-xs text-accent-600 hover:text-accent-700 hover:underline">Edit again</button> | |
| </div> | |
| <div class="line-numbered" x-html="renderDiffHtml(activePage.diff)"></div> | |
| </div> | |
| </template> | |
| <template x-if="!activePage?.diff?.length || editing"> | |
| <div> | |
| <div class="flex items-center justify-between mb-2"> | |
| <span class="lbl !mb-0">Transcription editor</span> | |
| <span class="text-[10px] text-ink-400" x-show="editorText" x-text="editorText.split('\\n').length + ' line(s)'"></span> | |
| </div> | |
| <textarea x-model="editorText" @focus="editing = true" class="editor" placeholder="Click 'Run OCR' to populate, then edit here. Validate to compute CER/WER and highlight your edits." spellcheck="false"></textarea> | |
| </div> | |
| </template> | |
| <!-- Structured JSON response (only when OCR was run in custom_json mode) --> | |
| <details class="mt-4" x-show="activePage?.ocr_structured" x-cloak> | |
| <summary class="text-xs text-ink-500 cursor-pointer hover:text-ink-700 select-none font-medium"> | |
| Structured response (custom JSON template) | |
| </summary> | |
| <pre class="mt-2 text-[11px] bg-ink-50 border border-ink-200 rounded-lg p-3 overflow-x-auto font-mono text-ink-700 whitespace-pre" x-text="JSON.stringify(activePage?.ocr_structured || null, null, 2)"></pre> | |
| </details> | |
| </div> | |
| <div x-show="!activePage" class="flex-1 flex flex-col items-center justify-center text-ink-400 p-8 text-center"> | |
| <p class="text-sm font-medium text-ink-600">Select or upload an image to start.</p> | |
| </div> | |
| </div> | |
| </template> | |
| <!-- Post-correction mode --> | |
| <template x-if="state.mode === 'post_correction'"> | |
| <div class="flex flex-col h-full"> | |
| <div class="px-4 py-3 border-b border-ink-200 flex items-center gap-2 flex-wrap"> | |
| <button @click="runMoe()" :disabled="busy || !(postCorrText && (activePage || pcImageId))" class="btn btn-primary"> | |
| <template x-if="!busy"><span class="inline-flex items-center gap-1.5">▶ Run MoE</span></template> | |
| <template x-if="busy"><span class="inline-flex items-center gap-2"><span class="spinner"></span>Experts + judge…</span></template> | |
| </button> | |
| <div class="text-[10px] text-ink-500 ml-2 leading-snug font-mono"> | |
| <div>A · <span x-text="state.settings.moe_expert_a"></span></div> | |
| <div>B · <span x-text="state.settings.moe_expert_b"></span></div> | |
| <div>J · <span x-text="state.settings.moe_judge"></span></div> | |
| </div> | |
| <div class="ml-auto inline-flex items-center gap-1.5"> | |
| <a :href="(activePage && activePage.moe) ? `/api/page/${activePageIdx}/export?fmt=json` : '#'" :class="!(activePage && activePage.moe) ? 'pointer-events-none opacity-40' : ''" download class="btn btn-ghost text-xs">⇩ MoE JSON</a> | |
| <a :href="(activePage && activePage.moe) ? `/api/page/${activePageIdx}/export?fmt=txt` : '#'" :class="!(activePage && activePage.moe) ? 'pointer-events-none opacity-40' : ''" download class="btn btn-ghost text-xs">⇩ TXT</a> | |
| </div> | |
| </div> | |
| <div class="px-4 py-2 border-b border-ink-200 text-xs bg-ink-50/60 flex items-center gap-2 flex-wrap"> | |
| <span class="text-ink-500">Image:</span> | |
| <span x-show="activePage" class="font-medium text-ink-700">active page · <span class="font-mono" x-text="state.images[activePage?.image_id]?.filename"></span></span> | |
| <span x-show="!activePage && pcImageId" class="font-medium text-ink-700">uploaded · <span class="font-mono" x-text="state.images[pcImageId]?.filename"></span></span> | |
| <span x-show="!activePage && !pcImageId" class="text-red-600 font-medium">no image — upload one</span> | |
| <button @click="$refs.pcFileInput?.click()" class="btn btn-ghost text-xs ml-auto">Upload another image</button> | |
| <input type="file" accept="image/*" class="sr-only" x-ref="pcFileInput" | |
| @change="uploadForPostCorr($event.target.files); $event.target.value=''" /> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-4 flex flex-col gap-4"> | |
| <div> | |
| <span class="lbl">OCR text to correct</span> | |
| <textarea x-model="postCorrText" rows="8" class="input" placeholder="Paste OCR text here. If the active page already has OCR text, it will be auto-filled."></textarea> | |
| </div> | |
| <template x-if="moeResult"> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center gap-2 text-xs mb-3 flex-wrap"> | |
| <span class="badge badge-corrected" x-text="moeResult.source"></span> | |
| <span class="accuracy-pill accuracy-pill-mid" x-text="`conf ${(moeResult.confidence*100).toFixed(0)}%`"></span> | |
| <span class="accuracy-pill" :class="metricClass(moeResult.cer)" x-text="`CER ${(moeResult.cer*100).toFixed(2)}%`"></span> | |
| <span class="accuracy-pill" :class="metricClass(moeResult.wer)" x-text="`WER ${(moeResult.wer*100).toFixed(2)}%`"></span> | |
| <template x-if="moeResult.usage_total"> | |
| <span class="accuracy-pill" | |
| :title="`${moeResult.usage_total.prompt_tokens} in + ${moeResult.usage_total.completion_tokens} out tokens`" | |
| x-text="formatCost(moeResult.usage_total.cost) + ' · ' + moeResult.usage_total.total_tokens + ' tok'"></span> | |
| </template> | |
| </div> | |
| <p class="text-xs italic text-ink-500 mb-3" x-text="moeResult.rationale"></p> | |
| <span class="lbl">Final corrected text (diff vs OCR)</span> | |
| <div class="line-numbered border border-ink-200 rounded-lg p-3 bg-white" x-html="renderDiffHtml(moeResult.diff)"></div> | |
| <details class="mt-3"> | |
| <summary class="text-xs text-ink-500 cursor-pointer hover:text-ink-700 select-none">Per-model details</summary> | |
| <div class="mt-2 grid grid-cols-1 gap-2"> | |
| <template x-for="key in ['expert_a','expert_b','judge']" :key="key"> | |
| <div class="border border-ink-200 rounded-lg p-3 bg-ink-50" x-show="moeResult[key]"> | |
| <div class="text-xs font-semibold flex items-center gap-2 flex-wrap"> | |
| <span x-text="`${key} · ${moeResult[key].model}`"></span> | |
| <template x-if="moeResult[key].usage"> | |
| <span class="accuracy-pill text-[10px]" | |
| :title="`${moeResult[key].usage.prompt_tokens} in + ${moeResult[key].usage.completion_tokens} out tokens`" | |
| x-text="formatCost(moeResult[key].usage.cost) + ' · ' + moeResult[key].usage.total_tokens + ' tok'"></span> | |
| </template> | |
| </div> | |
| <div class="text-[11px] text-ink-500 mt-0.5" x-text="`ok=${moeResult[key].ok} · latency=${moeResult[key].latency_s?.toFixed(2)}s · confidence=${moeResult[key].confidence}`"></div> | |
| <pre class="whitespace-pre-wrap font-mono text-[11px] mt-2 text-ink-700" x-text="moeResult[key].corrected_text || moeResult[key].final_text || moeResult[key].error"></pre> | |
| </div> | |
| </template> | |
| </div> | |
| </details> | |
| </div> | |
| </template> | |
| </div> | |
| </div> | |
| </template> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| <!-- ===================================================================== | |
| FOOTER | |
| ===================================================================== --> | |
| <footer class="mt-8 border-t border-ink-200 bg-white"> | |
| <div class="max-w-[1400px] mx-auto px-5 py-5 flex flex-col md:flex-row md:items-center gap-4"> | |
| <div class="flex items-center gap-6 flex-wrap"> | |
| <img src="/static/logos/distam.png" alt="DISTAM+" class="h-8 w-auto opacity-80" /> | |
| <img src="/static/logos/logo-noir-texte-droite.png" alt="Calfa" class="h-8 w-auto opacity-80" /> | |
| <img src="/static/logos/chartes.png" alt="École nationale des chartes — PSL" class="h-6 w-auto opacity-80" /> | |
| <img src="/static/logos/icdar-blocks-c-cut-traced-transparent-small.png" alt="ICDAR" class="h-7 w-auto opacity-80" /> | |
| </div> | |
| <div class="md:ml-auto text-[11px] text-ink-500 leading-relaxed md:text-right space-y-1"> | |
| <div> | |
| For text-only or linguistic annotation, see our sister app: | |
| <a href="https://huggingface.co/spaces/ENC-PSL/lrec2026-llm-annotator" target="_blank" rel="noopener" | |
| class="underline font-medium text-accent-700 hover:text-accent-600">LLM-as-annotator for text & linguistics</a>. | |
| </div> | |
| </div> | |
| </div> | |
| </footer> | |
| <!-- ===================================================================== | |
| TOASTS | |
| ===================================================================== --> | |
| <div x-show="toast" x-cloak class="fixed bottom-4 right-4 z-50"> | |
| <div :class="toast?.kind === 'error' ? 'toast-error' : (toast?.kind === 'success' ? 'toast-ok' : '')" class="toast" x-text="toast?.message"></div> | |
| </div> | |
| <!-- ===================================================================== | |
| SETTINGS MODAL (tabs) | |
| ===================================================================== --> | |
| <div x-show="modal === 'settings'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-3xl"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">Settings</h2> | |
| <!-- Tabs --> | |
| <div class="ml-4 inline-flex bg-ink-100 rounded-lg p-0.5 border border-ink-200 text-xs"> | |
| <button @click="settingsTab='general'" :class="settingsTab==='general' ? 'bg-white shadow-sm text-ink-900' : 'text-ink-500'" class="px-3 py-1 rounded-md font-medium">General</button> | |
| <button @click="settingsTab='ocr'" :class="settingsTab==='ocr' ? 'bg-white shadow-sm text-ink-900' : 'text-ink-500'" class="px-3 py-1 rounded-md font-medium">OCR</button> | |
| <button @click="settingsTab='moe'" :class="settingsTab==='moe' ? 'bg-white shadow-sm text-ink-900' : 'text-ink-500'" class="px-3 py-1 rounded-md font-medium">Post-corr (MoE)</button> | |
| </div> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon ml-auto">✕</button> | |
| </div> | |
| <!-- ── General tab ─────────────────────────────────────────────────── --> | |
| <div x-show="settingsTab === 'general'" class="p-5 grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div class="md:col-span-2"> | |
| <span class="lbl">Language / script</span> | |
| <input x-model="state.settings.language" list="lang-list" class="input" | |
| placeholder="e.g. Latin médiéval, Armenian classical, French 17th c." /> | |
| <p class="text-[11px] text-ink-400 mt-1">Substituted as <code class="bg-ink-100 px-1 rounded">{language}</code> in every system prompt (OCR + Expert + Judge).</p> | |
| <datalist id="lang-list"> | |
| <option>Latin</option><option>Latin médiéval</option> | |
| <option>French</option><option>French 17th c.</option> | |
| <option>Armenian</option><option>Greek</option> | |
| <option>Syriac</option><option>Arabic</option><option>Hebrew</option> | |
| </datalist> | |
| </div> | |
| <div class="md:col-span-2"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">Transcription policy (= guidelines)</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('guidelines', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.guidelines" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.guidelines" rows="6" class="input" | |
| placeholder="e.g. Preserve abbreviation marks; do not modernise spelling."></textarea> | |
| <p class="text-[11px] text-ink-400 mt-1">Source of truth for HOW to transcribe (preserve / expand / modernise). Substituted as <code class="bg-ink-100 px-1 rounded">{guidelines}</code> in OCR + Expert + Judge system prompts.</p> | |
| </div> | |
| <div> | |
| <span class="lbl">Temperature (all models)</span> | |
| <input type="number" min="0" max="2" step="0.05" x-model.number="state.settings.temperature" class="input" /> | |
| </div> | |
| <datalist id="model-list"> | |
| <template x-for="m in state.curated_models" :key="m"><option :value="m"></option></template> | |
| </datalist> | |
| </div> | |
| <!-- ── OCR tab ─────────────────────────────────────────────────────── --> | |
| <div x-show="settingsTab === 'ocr'" class="p-5 space-y-4"> | |
| <div class="p-3 bg-accent-50 border border-accent-200 rounded-lg"> | |
| <span class="lbl">Output format</span> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs"> | |
| <label class="flex items-start gap-2 p-3 bg-white border rounded cursor-pointer transition-colors" :class="state.settings.output_mode === 'lines' ? 'border-accent-500 ring-1 ring-accent-200' : 'border-ink-200 hover:border-ink-300'"> | |
| <input type="radio" value="lines" x-model="state.settings.output_mode" class="mt-0.5" /> | |
| <span class="block"> | |
| <span class="font-semibold block text-ink-900">Plain text lines</span> | |
| <span class="text-ink-500">The model returns one transcribed line per physical line. Simplest, recommended for first use.</span> | |
| </span> | |
| </label> | |
| <label class="flex items-start gap-2 p-3 bg-white border rounded cursor-pointer transition-colors" :class="state.settings.output_mode === 'custom_json' ? 'border-accent-500 ring-1 ring-accent-200' : 'border-ink-200 hover:border-ink-300'"> | |
| <input type="radio" value="custom_json" x-model="state.settings.output_mode" class="mt-0.5" /> | |
| <span class="block"> | |
| <span class="font-semibold block text-ink-900">Custom JSON template</span> | |
| <span class="text-ink-500">You paste an example JSON below; the model fills it. Use this for page metadata, abbreviation expansion, catalogue entries, etc.</span> | |
| </span> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- JSON template editor, only shown when custom_json mode is on --> | |
| <div x-show="state.settings.output_mode === 'custom_json'" x-cloak class="border border-ink-200 rounded-lg p-3 bg-ink-50/40"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">JSON template the model must fill</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('json_template', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load example…</option> | |
| <template x-for="n in presetNames.json_template" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.output_json_template = ''" class="btn btn-ghost text-xs">Clear</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.output_json_template" rows="10" | |
| class="input font-mono text-xs" | |
| placeholder='Paste an example JSON shape here, e.g.: { "no_page": "12r", "titre": "Liber primus", "lines": ["line 1", "line 2"] }'></textarea> | |
| <p class="text-[11px] text-ink-500 mt-1.5 leading-relaxed"> | |
| The model is asked to return <strong>this exact shape</strong>, with placeholder values replaced by content from the page. | |
| Use <code class="bg-ink-100 px-1 rounded">null</code> for fields that have no value on the page; arrays receive one entry per line/item. | |
| Try a preset above to see common shapes, then edit to match your needs. | |
| </p> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <div class="flex items-baseline justify-between gap-2"> | |
| <span class="lbl">OCR model (OpenRouter slug)</span> | |
| <a href="https://openrouter.ai/models" target="_blank" rel="noopener" | |
| class="text-[11px] text-accent-600 hover:underline">browse models ↗</a> | |
| </div> | |
| <input x-model="state.settings.ocr_model" list="model-list" class="input font-mono text-xs" /> | |
| </div> | |
| <div> | |
| <span class="lbl">N in-context examples</span> | |
| <input type="number" min="0" max="8" step="1" x-model.number="state.settings.n_icl" class="input" /> | |
| </div> | |
| <label class="flex items-center gap-2 mt-1 cursor-pointer"> | |
| <input type="checkbox" x-model="state.settings.use_icl" class="w-4 h-4 rounded border-ink-300 text-accent-600 focus:ring-accent-500" /> | |
| <span class="text-sm text-ink-700">Inject ICL examples in OCR prompt (few-shot)</span> | |
| </label> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">OCR system prompt</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('ocr_system', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.ocr_system" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_ocr_system = null" class="btn btn-ghost text-xs">Reset to default</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_ocr_system" | |
| :placeholder="defaultPrompts.ocr_system" | |
| rows="9" class="input"></textarea> | |
| <p class="text-[11px] text-ink-400 mt-1"><code>{language}</code> + <code>{guidelines}</code> substituted at call-time. Empty = the default for the selected Output mode above.</p> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">OCR user prompt (zero-shot)</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('ocr_user', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.ocr_user" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_ocr_user = null" class="btn btn-ghost text-xs">Reset</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_ocr_user" | |
| :placeholder="defaultPrompts.ocr_user" | |
| rows="4" class="input"></textarea> | |
| <p class="text-[11px] text-ink-400 mt-1">Empty = use the default shown as placeholder above.</p> | |
| </div> | |
| </div> | |
| <!-- ── MoE tab ─────────────────────────────────────────────────────── --> | |
| <div x-show="settingsTab === 'moe'" class="p-5 space-y-4"> | |
| <div class="flex justify-end -mb-2"> | |
| <a href="https://openrouter.ai/models" target="_blank" rel="noopener" | |
| class="text-[11px] text-accent-600 hover:underline">browse OpenRouter models ↗</a> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <span class="lbl">MoE expert A</span> | |
| <input x-model="state.settings.moe_expert_a" list="model-list" class="input font-mono text-xs" /> | |
| </div> | |
| <div> | |
| <span class="lbl">MoE expert B</span> | |
| <input x-model="state.settings.moe_expert_b" list="model-list" class="input font-mono text-xs" /> | |
| </div> | |
| <div class="md:col-span-2"> | |
| <span class="lbl">MoE judge (invoked only if A and B disagree)</span> | |
| <input x-model="state.settings.moe_judge" list="model-list" class="input font-mono text-xs" /> | |
| </div> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">Expert system prompt</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('expert_system', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.expert_system" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_expert_system = null" class="btn btn-ghost text-xs">Reset</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_expert_system" | |
| :placeholder="defaultPrompts.expert_system" | |
| rows="9" class="input"></textarea> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">Expert user prompt</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('expert_user', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.expert_user" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_expert_user = null" class="btn btn-ghost text-xs">Reset</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_expert_user" | |
| :placeholder="defaultPrompts.expert_user" | |
| rows="4" class="input"></textarea> | |
| <p class="text-[11px] text-ink-400 mt-1"><code>{ocr_text}</code> = OCR prediction. Empty = default (placeholder).</p> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">Judge system prompt</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('judge_system', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.judge_system" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_judge_system = null" class="btn btn-ghost text-xs">Reset</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_judge_system" | |
| :placeholder="defaultPrompts.judge_system" | |
| rows="9" class="input"></textarea> | |
| </div> | |
| <div class="border-t border-ink-200 pt-4"> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">Judge user prompt</span> | |
| <div class="flex items-center gap-2"> | |
| <select @change="applyPreset('judge_user', $event.target.value); $event.target.value=''" class="input !py-1 !px-2 text-xs"> | |
| <option value="">Load preset…</option> | |
| <template x-for="n in presetNames.judge_user" :key="n"><option :value="n" x-text="n"></option></template> | |
| </select> | |
| <button @click="state.settings.custom_judge_user = null" class="btn btn-ghost text-xs">Reset</button> | |
| </div> | |
| </div> | |
| <textarea x-model="state.settings.custom_judge_user" | |
| :placeholder="defaultPrompts.judge_user" | |
| rows="6" class="input"></textarea> | |
| <p class="text-[11px] text-ink-400 mt-1">Variables: <code>{ocr_text}</code>, <code>{expert_a_text}</code>, <code>{expert_b_text}</code>, <code>{expert_a_conf}</code>, <code>{expert_b_conf}</code>, <code>{expert_a_corrections}</code>, <code>{expert_b_corrections}</code>. Empty = default.</p> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button @click="modal=null" class="btn btn-secondary">Close</button> | |
| <button @click="saveSettings()" class="btn btn-primary">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| API KEY MODAL | |
| ===================================================================== --> | |
| <div x-show="modal === 'key'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-md"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">OpenRouter API key</h2> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon ml-auto">✕</button> | |
| </div> | |
| <div class="p-5 space-y-3 text-sm"> | |
| <p class="text-xs text-ink-500 leading-relaxed">Sent in the <code class="bg-ink-100 px-1 py-0.5 rounded">X-API-Key</code> header on each request. Stored only in <code class="bg-ink-100 px-1 py-0.5 rounded">localStorage</code> of this browser, never on the server.</p> | |
| <input type="password" x-model="apiKey" placeholder="sk-or-v1-…" class="input font-mono text-xs" /> | |
| <div class="flex gap-2 pt-1"> | |
| <button @click="testKey()" :disabled="!apiKey" class="btn btn-primary">Test connection</button> | |
| <button @click="modal=null" class="btn btn-secondary">Close</button> | |
| </div> | |
| <p class="text-xs leading-relaxed" :class="keyTestMsg?.startsWith('OK') ? 'text-green-700' : 'text-red-600'" x-text="keyTestMsg"></p> | |
| <p class="text-xs text-ink-400" x-show="state.env_key_present">An <code class="bg-ink-100 px-1 py-0.5 rounded">OPENROUTER_API_KEY</code> env variable is detected on the server and will be used as fallback.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| ICL POOL MODAL | |
| ===================================================================== --> | |
| <div x-show="modal === 'icl'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-3xl"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">ICL pool · | |
| <span class="text-ink-400 font-normal" x-text="visibleIclPool.length + ' examples'"></span> | |
| </h2> | |
| <div class="ml-auto flex gap-2"> | |
| <a href="/api/icl/export" download="icl_pool.jsonl" class="btn btn-secondary text-xs">⇩ Export JSONL</a> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon">✕</button> | |
| </div> | |
| </div> | |
| <div class="p-5"> | |
| <p class="text-xs text-ink-500 mb-3"> | |
| <template x-if="activePage && activePage.is_sandbox"> | |
| <span>Showing the ICL usable for sandbox <strong x-text="activePage.short_label"></strong> (language <code class="text-[11px] bg-ink-100 px-1 py-0.5 rounded" x-text="activePage.language"></code>). Switch to another page to see its own pool.</span> | |
| </template> | |
| <template x-if="!activePage || !activePage.is_sandbox"> | |
| <span>Showing your own ICL entries (sandbox-seeded examples are hidden here — they appear only when their sandbox page is active).</span> | |
| </template> | |
| </p> | |
| <ul class="space-y-2"> | |
| <template x-for="ex in visibleIclPool" :key="ex.id"> | |
| <li class="border border-ink-200 rounded-lg p-3 flex gap-3 items-start bg-white hover:bg-ink-50 transition-colors"> | |
| <img :src="'data:image/jpeg;base64,' + ex.image_b64" class="w-24 h-24 object-cover rounded bg-ink-100 border border-ink-200 flex-shrink-0" /> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex items-center gap-2 text-[11px] mb-1.5"> | |
| <span class="badge" | |
| :class="ex.source==='pasted' ? 'badge-uploaded' : (ex.source==='sandbox' ? 'badge-sandbox' : 'badge-corrected')" | |
| x-text="ex.source"></span> | |
| <span class="text-ink-500" x-text="ex.language || '—'"></span> | |
| <span class="text-ink-300">·</span> | |
| <span class="text-ink-500" x-text="`${ex.n_lines} lines`"></span> | |
| </div> | |
| <pre class="text-xs whitespace-pre-wrap font-mono text-ink-700" x-text="ex.text.slice(0, 400) + (ex.text.length > 400 ? '…' : '')"></pre> | |
| </div> | |
| <button @click="removeIcl(ex.id)" class="text-ink-300 hover:text-red-600 text-sm w-6 h-6 grid place-items-center flex-shrink-0" title="Remove from pool">✕</button> | |
| </li> | |
| </template> | |
| <li x-show="visibleIclPool.length === 0" class="text-sm text-ink-400 text-center py-8"> | |
| <p class="font-medium text-ink-500">No ICL for this page yet.</p> | |
| <p class="text-xs mt-1" x-show="activePage && activePage.is_sandbox">This sandbox is a 0-shot demo — or all its examples were deleted.</p> | |
| <p class="text-xs mt-1" x-show="!activePage || !activePage.is_sandbox">Validate a correction or paste a transcription with the quick-add tool.</p> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| OCR PROMPT PREVIEW MODAL | |
| ===================================================================== --> | |
| <div x-show="modal === 'preview'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-3xl"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">OCR prompt preview</h2> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon ml-auto">✕</button> | |
| </div> | |
| <div class="p-5 space-y-4 text-sm"> | |
| <template x-if="ocrPreview"> | |
| <div class="space-y-4"> | |
| <div class="grid grid-cols-2 gap-3 text-xs"> | |
| <div class="card !p-2.5"> | |
| <div class="card-title !mb-1">Model</div> | |
| <div class="font-mono text-[11px]" x-text="ocrPreview.model"></div> | |
| </div> | |
| <div class="card !p-2.5"> | |
| <div class="card-title !mb-1">Output mode</div> | |
| <div class="font-mono text-[11px]" x-text="ocrPreview.output_mode"></div> | |
| </div> | |
| <div class="card !p-2.5 col-span-2" :class="ocrPreview.icl.n_injected > 0 ? 'bg-ok-50 border-ok-200' : ''"> | |
| <div class="card-title !mb-1">In-Context Learning (few-shot)</div> | |
| <div class="text-[11px]"> | |
| <span x-show="!ocrPreview.icl.enabled" class="text-ink-500">Disabled in settings.</span> | |
| <span x-show="ocrPreview.icl.enabled && ocrPreview.icl.n_injected === 0" class="text-warn-700"> | |
| Enabled, but <strong>0 example</strong> will be injected | |
| (pool size: <span x-text="ocrPreview.icl.pool_size"></span><span x-show="ocrPreview.icl.language_filter">, language filter: <span x-text="ocrPreview.icl.language_filter"></span></span>). | |
| </span> | |
| <span x-show="ocrPreview.icl.enabled && ocrPreview.icl.n_injected > 0" class="text-ok-700"> | |
| <strong x-text="ocrPreview.icl.n_injected"></strong> example(s) will be injected | |
| (requested <span x-text="ocrPreview.icl.n_requested"></span>, pool size <span x-text="ocrPreview.icl.pool_size"></span><span x-show="ocrPreview.icl.language_filter">, language: <span x-text="ocrPreview.icl.language_filter"></span></span>). | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <span class="lbl">System message</span> | |
| <pre class="text-[11px] bg-ink-50 border border-ink-200 rounded-lg p-3 overflow-x-auto font-mono text-ink-700 whitespace-pre-wrap" x-text="ocrPreview.system"></pre> | |
| </div> | |
| <div> | |
| <span class="lbl">User message parts (in order)</span> | |
| <ol class="space-y-1.5"> | |
| <template x-for="(p, i) in ocrPreview.user_parts" :key="i"> | |
| <li class="border border-ink-200 rounded-lg p-2"> | |
| <div class="flex items-center gap-2 text-[10px] mb-1"> | |
| <span class="font-mono text-ink-400 w-6 text-right" x-text="`#${i+1}`"></span> | |
| <span class="badge" | |
| :class="p.type === 'image' ? 'badge-uploaded' : 'badge-sandbox'" | |
| x-text="p.type"></span> | |
| </div> | |
| <template x-if="p.type === 'text'"> | |
| <pre class="text-[11px] whitespace-pre-wrap font-mono text-ink-700" x-text="p.text"></pre> | |
| </template> | |
| <template x-if="p.type === 'image'"> | |
| <div class="text-[11px] font-mono text-ink-500 italic" x-text="p.label"></div> | |
| </template> | |
| </li> | |
| </template> | |
| </ol> | |
| </div> | |
| </div> | |
| </template> | |
| <p x-show="!ocrPreview" class="text-ink-400 text-xs">Loading…</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| COST MODAL | |
| ===================================================================== --> | |
| <div x-show="modal === 'cost'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-md"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">Session cost</h2> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon ml-auto">✕</button> | |
| </div> | |
| <div class="p-5 space-y-3 text-sm"> | |
| <p class="text-xs text-ink-500 leading-relaxed">Reported by OpenRouter via <code class="bg-ink-100 px-1 py-0.5 rounded">usage.include = true</code> on each request.</p> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <div class="card !p-3 text-center"> | |
| <div class="card-title !mb-1">Total cost</div> | |
| <div class="font-mono font-semibold text-lg text-accent-700" x-text="formatCost(state.totals?.cost_usd)"></div> | |
| </div> | |
| <div class="card !p-3 text-center"> | |
| <div class="card-title !mb-1">Requests</div> | |
| <div class="font-mono font-semibold text-lg text-ink-800" x-text="state.totals?.requests || 0"></div> | |
| </div> | |
| <div class="card !p-3 text-center"> | |
| <div class="card-title !mb-1">Input tokens</div> | |
| <div class="font-mono font-semibold text-lg text-ink-800" x-text="(state.totals?.input_tokens || 0).toLocaleString()"></div> | |
| </div> | |
| <div class="card !p-3 text-center"> | |
| <div class="card-title !mb-1">Output tokens</div> | |
| <div class="font-mono font-semibold text-lg text-ink-800" x-text="(state.totals?.output_tokens || 0).toLocaleString()"></div> | |
| </div> | |
| </div> | |
| <p class="text-[11px] text-ink-400 leading-relaxed">Some models don't report a per-call <code>usage.cost</code>. In that case tokens are counted but the cost stays at $0 — fall back to the OpenRouter activity dashboard for accurate billing.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===================================================================== | |
| CITATION MODAL | |
| ===================================================================== --> | |
| <div x-show="modal === 'cite'" x-cloak class="modal-backdrop" @click.self="modal=null"> | |
| <div class="modal-card max-w-xl"> | |
| <div class="modal-header"> | |
| <h2 class="font-semibold text-ink-900">Cite this work</h2> | |
| <button @click="modal=null" class="btn btn-ghost btn-icon ml-auto">✕</button> | |
| </div> | |
| <div class="p-5 space-y-4 text-sm"> | |
| <p class="text-xs text-ink-500">If you use HTR · VLM as Annotator in your research, please cite it as:</p> | |
| <div> | |
| <span class="lbl">APA</span> | |
| <p class="text-sm leading-relaxed text-ink-800" x-text="citation.apa"></p> | |
| </div> | |
| <div> | |
| <div class="flex items-center justify-between mb-1.5"> | |
| <span class="lbl !mb-0">BibTeX</span> | |
| <button @click="copyCitation()" class="btn btn-ghost text-xs">📋 Copy</button> | |
| </div> | |
| <pre class="text-[11px] bg-ink-50 border border-ink-200 rounded-lg p-3 overflow-x-auto font-mono text-ink-700 whitespace-pre" x-text="citation.bibtex"></pre> | |
| </div> | |
| <p class="text-[11px] text-ink-400">The MoE post-correction strategy is adapted from internal pipeline scripts; please also acknowledge the underlying VLM providers (OpenRouter routes Anthropic, Google, OpenAI, Mistral, etc.).</p> | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |