function htrApp() { return { // ── UI state ────────────────────────────────────────────────────────── state: { settings: {}, images: {}, pages: [], icl_pool: [], mode: "ocr", curated_models: [], env_key_present: false, }, apiKey: "", modal: null, settingsTab: "general", busy: false, toast: null, keyTestMsg: "", activePageIdx: -1, activeImageDataUrl: "", zoom: 1, panning: false, _panStart: null, imgFitW: 0, imgFitH: 0, containerW: 0, containerH: 0, editorText: "", editing: false, quickIclText: "", quickIclMode: "text", // "text" | "json" quickIclJsonError: "", annotateMode: false, annotateDefaultLabel: "undefined", samBusy: false, showDetections: true, postCorrText: "", pcImageId: "", moeResult: null, ocrPreview: null, defaultPrompts: {}, presetNames: { ocr_system: [], ocr_user: [], expert_system: [], expert_user: [], judge_system: [], judge_user: [] }, citation: { bibtex: "", apa: "" }, _thumbCache: {}, _toastTimer: null, // ── Lifecycle ───────────────────────────────────────────────────────── async init() { try { this.apiKey = localStorage.getItem("htr_openrouter_key") || ""; } catch (e) { this.apiKey = ""; } try { await Promise.all([this.refreshState(), this.fetchPrompts(), this.fetchCitation()]); if (this.state.pages.length > 0) await this.selectPage(0); } catch (e) { console.error("init failed:", e); this.showToast("Could not contact backend: " + e.message, "error", 8000); } this.$watch("apiKey", (v) => { try { localStorage.setItem("htr_openrouter_key", v || ""); } catch (e) {} }); // Keep image fit-math in sync with window resizes. window.addEventListener("resize", () => { const c = this.$refs.imgScroll; if (!c) return; const img = c.querySelector("img"); if (img && img.complete && img.naturalWidth) this.onImgLoad({ target: img }); }); }, async refreshState() { const r = await fetch("/api/state"); if (!r.ok) throw new Error("HTTP " + r.status); this.state = await r.json(); }, async fetchPrompts() { try { const r = await fetch("/api/prompts/defaults"); const d = await r.json(); this.defaultPrompts = d.defaults || {}; this.presetNames = d.preset_names || this.presetNames; } catch (e) { console.warn("fetchPrompts failed", e); } }, async fetchCitation() { try { const r = await fetch("/api/citation"); this.citation = await r.json(); } catch (e) {} }, // ── Computed ────────────────────────────────────────────────────────── get activePage() { if (this.activePageIdx < 0 || this.activePageIdx >= this.state.pages.length) return null; return this.state.pages[this.activePageIdx]; }, // ICL pool filtered to what's actually relevant for the active page. // - Sandbox page: only entries whose language matches the page (the same // set the OCR call would inject). // - Uploaded page (or no active page): only user-added entries (corrected/pasted). get visibleIclPool() { const pool = this.state.icl_pool || []; const p = this.activePage; if (p && p.is_sandbox) { return pool.filter(ex => ex.language === p.language); } return pool.filter(ex => ex.source !== "sandbox"); }, // ── Helpers ─────────────────────────────────────────────────────────── headers(extra = {}) { const h = { "Content-Type": "application/json", ...extra }; if (this.apiKey) h["X-API-Key"] = this.apiKey; return h; }, showToast(message, kind = "info", ms = 3500) { this.toast = { message, kind }; clearTimeout(this._toastTimer); this._toastTimer = setTimeout(() => (this.toast = null), ms); }, statusLabel(s) { return ({ pending: "pending", ocr_running: "running OCR…", ocr_done: "OCR done", corrected: "corrected", moe_running: "MoE running…", moe_done: "MoE done", error: "error", })[s] || s || "—"; }, dotClass(s) { return ({ pending: "dot-pending", ocr_running: "dot-busy", moe_running: "dot-busy", ocr_done: "dot-ok", corrected: "dot-ok", moe_done: "dot-ok", error: "dot-error", })[s] || "dot-pending"; }, metricClass(v) { if (v == null) return ""; if (v < 0.05) return "accuracy-pill-high"; if (v < 0.15) return "accuracy-pill-mid"; return "accuracy-pill-low"; }, formatCost(v) { if (v == null || isNaN(v)) return "—"; if (v === 0) return "$0"; if (v < 0.001) return "$" + v.toExponential(2); if (v < 1) return "$" + v.toFixed(4); return "$" + v.toFixed(2); }, thumbDataUrl(imageId) { if (this._thumbCache[imageId]) return this._thumbCache[imageId]; this._thumbCache[imageId] = ""; fetch(`/api/image/${imageId}`).then(r => r.json()).then(d => { this._thumbCache[imageId] = `data:image/jpeg;base64,${d.b64}`; }).catch(() => {}); return ""; }, openSettings(tab) { this.settingsTab = tab || "general"; this.modal = "settings"; }, async applyPreset(family, name) { if (!name) return; const r = await fetch(`/api/prompts/preset?family=${encodeURIComponent(family)}&name=${encodeURIComponent(name)}`); if (!r.ok) { this.showToast("Preset load failed", "error"); return; } const d = await r.json(); const fieldMap = { guidelines: "guidelines", json_template: "output_json_template", ocr_system: "custom_ocr_system", ocr_user: "custom_ocr_user", expert_system: "custom_expert_system", expert_user: "custom_expert_user", judge_system: "custom_judge_system", judge_user: "custom_judge_user", }; const key = fieldMap[family]; if (key) this.state.settings[key] = d.body; this.showToast(`Loaded preset: ${family}/${name}`, "success"); }, copyCitation() { navigator.clipboard?.writeText(this.citation.bibtex || "").then(() => { this.showToast("BibTeX copied to clipboard.", "success"); }).catch(() => { this.showToast("Could not copy.", "error"); }); }, // ── Mode / selection ────────────────────────────────────────────────── async setMode(m) { this.state.mode = m; try { await fetch(`/api/mode/${m}`, { method: "POST" }); } catch (e) {} // When switching to post-corr, auto-fill the textarea from the active page's OCR text if (m === "post_correction" && this.activePage) { const p = this.activePage; this.postCorrText = p.ocr_text || p.corrected_text || this.postCorrText || ""; this.moeResult = p.moe || null; } }, // Compute the contain-fit size for the loaded image and capture the // current viewport size of the scroll container. The wrapper's CSS in // the template grows with `imgFit{W,H} * zoom + container{W,H}` so the // scroll container has real layout space matching the displayed image // — plus a viewport-sized margin of slack at zoom > 1 to allow panning // past any edge into empty canvas. onImgLoad(e) { const img = e.target; const container = this.$refs.imgScroll; if (!container || !img.naturalWidth) return; const cw = container.clientWidth; const ch = container.clientHeight; if (cw <= 0 || ch <= 0) return; this.containerW = cw; this.containerH = ch; const fit = Math.min(cw / img.naturalWidth, ch / img.naturalHeight, 1); this.imgFitW = img.naturalWidth * fit; this.imgFitH = img.naturalHeight * fit; }, // Click-and-drag panning of the zoomed image (scroll-based, no transform). startPan(e) { // Annotate mode owns clicks: never start panning while it's on. if (this.annotateMode) return; const el = this.$refs.imgScroll; if (!el) return; // Only pan when zoomed in (otherwise the image fits, nothing to pan). if (this.zoom <= 1) return; if (e.button !== 0) return; // left click only this.panning = true; this._panStart = { x: e.clientX, y: e.clientY, sx: el.scrollLeft, sy: el.scrollTop, }; e.preventDefault(); }, onPan(e) { if (!this.panning || !this._panStart) return; const el = this.$refs.imgScroll; if (!el) return; el.scrollLeft = this._panStart.sx - (e.clientX - this._panStart.x); el.scrollTop = this._panStart.sy - (e.clientY - this._panStart.y); }, endPan() { this.panning = false; this._panStart = null; }, async selectPage(idx) { this.activePageIdx = idx; this.editing = false; this.zoom = 1; this.imgFitW = 0; this.imgFitH = 0; const p = this.activePage; if (!p) { this.activeImageDataUrl = ""; this.editorText = ""; this.postCorrText = ""; this.moeResult = null; return; } // Restore per-page state: editor text, post-corr text, MoE result this.editorText = p.corrected_text || p.ocr_text || ""; this.postCorrText = p.ocr_text || p.corrected_text || ""; this.moeResult = p.moe || null; try { const r = await fetch(`/api/image/${p.image_id}`); const d = await r.json(); this.activeImageDataUrl = `data:image/jpeg;base64,${d.b64}`; } catch (e) { this.activeImageDataUrl = ""; } }, // ── Upload / sandbox ────────────────────────────────────────────────── async uploadFiles(files) { if (!files || files.length === 0) return; const list = Array.from(files).filter(f => f.type.startsWith("image/")); if (list.length === 0) { this.showToast("No image files in the drop.", "error"); return; } const fd = new FormData(); for (const f of list) fd.append("files", f); this.busy = true; try { const r = await fetch("/api/upload", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; // Jump to the first newly-created page so the user sees what they just uploaded. const newIds = d.created_pages || []; const firstNewIdx = newIds.length ? this.state.pages.findIndex(p => p.id === newIds[0]) : -1; if (firstNewIdx >= 0) await this.selectPage(firstNewIdx); else if (this.activePageIdx < 0 && this.state.pages.length > 0) await this.selectPage(0); this.showToast(`Uploaded ${list.length} image${list.length > 1 ? "s" : ""}.`, "success"); } catch (e) { console.error("upload failed:", e); this.showToast("Upload failed: " + e.message, "error"); } finally { this.busy = false; } }, async resetSession() { if (!confirm("Reset session?\n\nThis clears EVERYTHING: pages, ICL pool, cost totals, language, models, prompts, JSON template, guidelines, and all custom overrides. Sandbox samples are reloaded. Only your API key (stored locally in this browser) is kept.")) return; this.busy = true; try { const r = await fetch("/api/session/reset", { method: "POST" }); if (!r.ok) throw new Error(await r.text()); this.state = await r.json(); // Wipe all per-page / per-session UI state so nothing lingers. this.activePageIdx = -1; this.activeImageDataUrl = ""; this.zoom = 1; this.panning = false; this._panStart = null; this.imgFitW = 0; this.imgFitH = 0; this.editorText = ""; this.editing = false; this.quickIclText = ""; this.quickIclMode = "text"; this.quickIclJsonError = ""; this.annotateMode = false; this.annotateDefaultLabel = "undefined"; this.samBusy = false; this.showDetections = true; this.postCorrText = ""; this.pcImageId = ""; this.moeResult = null; this.ocrPreview = null; this.settingsTab = "general"; this.keyTestMsg = ""; this.modal = null; this._thumbCache = {}; if (this.state.pages.length > 0) await this.selectPage(0); this.showToast("Session reset.", "success"); } catch (e) { this.showToast("Reset failed: " + e.message, "error"); } finally { this.busy = false; } }, async deletePage(idx) { if (!confirm("Remove this page?")) return; try { const r = await fetch(`/api/page/${idx}`, { method: "DELETE" }); if (!r.ok) throw new Error(await r.text()); this.state = await r.json(); if (this.activePageIdx >= this.state.pages.length) this.activePageIdx = this.state.pages.length - 1; await this.selectPage(this.activePageIdx); } catch (e) { this.showToast("Delete failed: " + e.message, "error"); } }, // ── Settings & key ──────────────────────────────────────────────────── async saveSettings() { try { const r = await fetch("/api/settings", { method: "POST", headers: this.headers(), body: JSON.stringify(this.state.settings), }); if (!r.ok) throw new Error(await r.text()); this.state = await r.json(); this.modal = null; this.showToast("Settings saved.", "success"); } catch (e) { this.showToast("Save failed: " + e.message, "error"); } }, async testKey() { this.keyTestMsg = "Testing…"; try { const r = await fetch("/api/settings/test_key", { method: "POST", headers: this.headers(), body: JSON.stringify({ model: this.state.settings.moe_judge || null }), }); const d = await r.json(); this.keyTestMsg = (d.ok ? "OK · " : "FAIL · ") + d.message; } catch (e) { this.keyTestMsg = "FAIL · " + e.message; } }, // ── OCR mode actions ────────────────────────────────────────────────── async previewOcrPrompt() { if (!this.activePage) return; this.ocrPreview = null; this.modal = "preview"; try { const r = await fetch(`/api/ocr/preview/${this.activePageIdx}`); if (!r.ok) throw new Error(await r.text()); this.ocrPreview = await r.json(); } catch (e) { this.showToast("Preview failed: " + e.message, "error"); } }, async runOcr() { if (!this.activePage) return; this.busy = true; try { const r = await fetch("/api/ocr", { method: "POST", headers: this.headers(), body: JSON.stringify({ page_idx: this.activePageIdx }), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; this.editorText = d.ocr_text; this.editing = true; this.showToast(`OCR done in ${d.latency_s.toFixed(1)}s.`, "success"); } catch (e) { this.showToast("OCR failed: " + e.message, "error", 6000); } finally { this.busy = false; } }, async validateAndScore(addToIcl = false) { if (!this.activePage) return; try { const r = await fetch("/api/correction", { method: "POST", headers: this.headers(), body: JSON.stringify({ page_idx: this.activePageIdx, corrected_text: this.editorText, add_to_icl: addToIcl, }), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; this.editing = false; this.showToast( addToIcl ? `Saved · CER ${(d.cer*100).toFixed(2)}% · WER ${(d.wer*100).toFixed(2)}% · added to ICL.` : `Scored · CER ${(d.cer*100).toFixed(2)}% · WER ${(d.wer*100).toFixed(2)}%.`, "success", ); } catch (e) { this.showToast("Validate failed: " + e.message, "error"); } }, async addCurrentToIcl() { await this.validateAndScore(true); }, // Convert a click event on the to natural-image pixel coords, then // ask FastSAM for the bbox of the mask under that pixel. async annotateClick(e) { if (!this.annotateMode || !this.activePage) return; e.stopPropagation(); e.preventDefault(); const img = e.currentTarget; const rect = img.getBoundingClientRect(); const nw = this.state.images?.[this.activePage.image_id]?.w || 0; const nh = this.state.images?.[this.activePage.image_id]?.h || 0; if (!rect.width || !rect.height || !nw || !nh) return; const x = Math.round((e.clientX - rect.left) * nw / rect.width); const y = Math.round((e.clientY - rect.top) * nh / rect.height); this.samBusy = true; try { const r = await fetch("/api/sam/point", { method: "POST", headers: this.headers(), body: JSON.stringify({ page_idx: this.activePageIdx, x, y, label: this.annotateDefaultLabel || "object", }), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; this.showDetections = true; } catch (err) { console.error("SAM failed:", err); this.showToast("Segment failed: " + err.message, "error"); } finally { this.samBusy = false; } }, async relabelAnnotation(id, label) { if (!this.activePage) return; try { const r = await fetch("/api/annotation/label", { method: "POST", headers: this.headers(), body: JSON.stringify({ page_idx: this.activePageIdx, annotation_id: id, label }), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; } catch (e) { this.showToast("Relabel failed: " + e.message, "error"); } }, async deleteAnnotation(id) { if (!this.activePage) return; try { const r = await fetch(`/api/page/${this.activePageIdx}/annotation/${id}`, { method: "DELETE" }); if (!r.ok) throw new Error(await r.text()); this.state = await r.json(); } catch (e) { this.showToast("Delete failed: " + e.message, "error"); } }, async quickAddIcl() { if (!this.activePage || !this.quickIclText) return; this.quickIclJsonError = ""; let payloadText = this.quickIclText; if (this.quickIclMode === "json") { try { // Validate + pretty-print so the pool entry shows clean JSON // and the few-shot prompt injects it canonically. payloadText = JSON.stringify(JSON.parse(this.quickIclText), null, 2); } catch (err) { this.quickIclJsonError = "Invalid JSON: " + err.message; return; } } try { const r = await fetch("/api/icl/paste", { method: "POST", headers: this.headers(), body: JSON.stringify({ image_id: this.activePage.image_id, text: payloadText, language: this.state.settings.language || "", }), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; this.quickIclText = ""; this.showToast("Added to ICL pool.", "success"); } catch (e) { this.showToast("Quick add failed: " + e.message, "error"); } }, sendToPostCorr() { this.postCorrText = this.editorText; this.setMode("post_correction"); }, // ── Post-correction mode ────────────────────────────────────────────── async uploadForPostCorr(files) { if (!files || files.length === 0) return; const list = Array.from(files).filter(f => f.type.startsWith("image/")); if (list.length === 0) return; const fd = new FormData(); for (const f of list) fd.append("files", f); this.busy = true; try { const r = await fetch("/api/upload", { method: "POST", body: fd }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; const lastPage = this.state.pages[this.state.pages.length - 1]; this.pcImageId = lastPage?.image_id || ""; this.showToast("Image ready for post-correction.", "success"); } catch (e) { this.showToast("Upload failed: " + e.message, "error"); } finally { this.busy = false; } }, async runMoe() { const body = { ocr_text: this.postCorrText, page_idx: this.activePage ? this.activePageIdx : null, image_id: this.activePage ? null : (this.pcImageId || null), }; this.busy = true; this.moeResult = null; try { const r = await fetch("/api/post_correct", { method: "POST", headers: this.headers(), body: JSON.stringify(body), }); if (!r.ok) throw new Error(await r.text()); const d = await r.json(); this.state = d.state; delete d.state; this.moeResult = d; this.showToast(`MoE done · source ${d.source} · conf ${(d.confidence*100).toFixed(0)}%`, "success"); } catch (e) { this.showToast("MoE failed: " + e.message, "error", 6000); } finally { this.busy = false; } }, // ── ICL pool ────────────────────────────────────────────────────────── async removeIcl(itemId) { try { const r = await fetch(`/api/icl/remove/${itemId}`, { method: "POST" }); if (!r.ok) throw new Error(await r.text()); this.state = await r.json(); } catch (e) { this.showToast("Remove failed: " + e.message, "error"); } }, // Per-bbox inline style. Coordinates are translated to percentages of the // natural image dimensions; the overlay div is the same size as the // (both children of the same relative wrapper), so this scales with zoom // automatically without any JS bookkeeping. bboxStyle(d) { const img = this.state.images?.[this.activePage?.image_id]; if (!img || !d || !Array.isArray(d.bbox_px)) return "display:none;"; const iw = img.w || 1; const ih = img.h || 1; const [x1, y1, x2, y2] = d.bbox_px; const left = (x1 / iw) * 100; const top = (y1 / ih) * 100; const w = ((x2 - x1) / iw) * 100; const h = ((y2 - y1) / ih) * 100; const color = d.color || "#e11d48"; return `left:${left}%; top:${top}%; width:${w}%; height:${h}%; border: 2px solid ${color};`; }, // ── Rendering helpers ───────────────────────────────────────────────── renderDiffHtml(diff) { if (!diff || diff.length === 0) return ""; const html = []; let currentLine = []; const flushLine = (n) => { html.push(`
${n}${currentLine.join("")}
`); currentLine = []; }; let lineNo = 1; for (const tok of diff) { if (tok.text.includes("\n")) { const parts = tok.text.split("\n"); for (let i = 0; i < parts.length; i++) { const piece = parts[i]; if (piece) currentLine.push(escapeHtml(piece)); if (i < parts.length - 1) { flushLine(lineNo); lineNo++; } } } else if (tok.edited) { currentLine.push(`${escapeHtml(tok.text)}`); } else { currentLine.push(escapeHtml(tok.text)); } } if (currentLine.length) flushLine(lineNo); return html.join(""); }, }; } function escapeHtml(s) { return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", }[c])); } window.htrApp = htrApp; window.escapeHtml = escapeHtml; // Global fallback: if Alpine doesn't fire `alpine:initialized` within 3s, warn. let __alpineReady = false; document.addEventListener("alpine:initialized", () => { __alpineReady = true; }); window.addEventListener("DOMContentLoaded", () => { setTimeout(() => { if (__alpineReady) return; const warn = document.createElement("div"); warn.style.cssText = "position:fixed;top:0;left:0;right:0;padding:.75rem 1rem;background:#b91c1c;color:#fff;font:14px system-ui;z-index:9999"; warn.textContent = "Alpine.js did not initialise — open the browser DevTools console for the error."; document.body.prepend(warn); }, 3000); });