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(`