htr-vlm-annotator / static /index.html
dhuser's picture
Update static/index.html
d0abc32 verified
Raw
History Blame Contribute Delete
65.5 kB
<!DOCTYPE html>
<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 &amp; 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 &amp; 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 &amp; 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.:&#10;{&#10; "no_page": "12r",&#10; "titre": "Liber primus",&#10; "lines": ["line 1", "line 2"]&#10;}'></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>