Spaces:
Runtime error
Runtime error
| ; | |
| /* ============================================================ | |
| Local PDF Extractor — frontend logic (dependency-free) | |
| ============================================================ */ | |
| (function () { | |
| // ---------- State ---------- | |
| const settings = { mode: "max", lang: "en", preprocess: true, binarize: false, handwriting: false, online: false, online_key: "", online_model: "", force_ocr: false, remove_headers: true }; | |
| // jobId -> { jobId, filename, totalPages, status, pages: Map<pageNo, pageData>, | |
| // el: {...}, source, es: EventSource } | |
| const jobs = new Map(); | |
| let jobOrder = []; // preserve display order | |
| // OCR lines below this confidence are highlighted as "to check". | |
| const LOWCONF_THRESH = 0.85; | |
| // ---------- Element refs ---------- | |
| const $ = (id) => document.getElementById(id); | |
| const modeToggle = $("modeToggle"); | |
| const modeHint = $("modeHint"); | |
| const langSelect = $("langSelect"); | |
| const preprocessToggle = $("preprocessToggle"); | |
| const binarizeToggle = $("binarizeToggle"); | |
| const forceOcrToggle = $("forceOcrToggle"); | |
| const removeHeadersToggle = $("removeHeadersToggle"); | |
| const handwritingToggle = $("handwritingToggle"); | |
| const onlineToggle = $("onlineToggle"); | |
| const onlineRow = $("onlineRow"); | |
| const onlineKey = $("onlineKey"); | |
| const onlineCheck = $("onlineCheck"); | |
| const onlineStatus = $("onlineStatus"); | |
| const onlineModel = $("onlineModel"); | |
| const dropzone = $("dropzone"); | |
| const fileInput = $("fileInput"); | |
| const toolbar = $("toolbar"); | |
| const emptyState = $("emptyState"); | |
| const filesContainer = $("filesContainer"); | |
| const searchInput = $("searchInput"); | |
| const searchCount = $("searchCount"); | |
| const copyAllBtn = $("copyAllBtn"); | |
| const toast = $("toast"); | |
| const fileCardTpl = $("fileCardTemplate"); | |
| const pageCardTpl = $("pageCardTemplate"); | |
| // ---------- Settings UI ---------- | |
| modeToggle.addEventListener("click", (e) => { | |
| const btn = e.target.closest(".seg"); | |
| if (!btn) return; | |
| settings.mode = btn.dataset.mode; | |
| modeToggle.querySelectorAll(".seg").forEach((b) => { | |
| const active = b === btn; | |
| b.classList.toggle("active", active); | |
| b.setAttribute("aria-checked", active ? "true" : "false"); | |
| }); | |
| modeHint.textContent = | |
| settings.mode === "max" | |
| ? "Max accuracy is slower but renders at higher DPI and uses angle correction." | |
| : "Faster mode renders at lower DPI and skips angle correction — quicker, slightly less accurate."; | |
| }); | |
| langSelect.addEventListener("change", () => { | |
| settings.lang = langSelect.value; | |
| }); | |
| preprocessToggle.addEventListener("change", () => { | |
| settings.preprocess = preprocessToggle.checked; | |
| // Binarize only makes sense when preprocessing is on. | |
| binarizeToggle.disabled = !preprocessToggle.checked; | |
| }); | |
| binarizeToggle.addEventListener("change", () => { | |
| settings.binarize = binarizeToggle.checked; | |
| }); | |
| if (forceOcrToggle) { | |
| forceOcrToggle.addEventListener("change", () => { | |
| settings.force_ocr = forceOcrToggle.checked; | |
| }); | |
| } | |
| if (removeHeadersToggle) { | |
| settings.remove_headers = removeHeadersToggle.checked; | |
| removeHeadersToggle.addEventListener("change", () => { | |
| settings.remove_headers = removeHeadersToggle.checked; | |
| }); | |
| } | |
| handwritingToggle.addEventListener("change", () => { | |
| settings.handwriting = handwritingToggle.checked; | |
| }); | |
| // ---------- Online vision OCR (Gemini, opt-in) ---------- | |
| const ONLINE_KEY_LS = "pdfx_gemini_key"; | |
| const ONLINE_MODEL_LS = "pdfx_gemini_model"; | |
| if (onlineToggle) { | |
| // Restore a previously saved key/model (stored only in this browser). | |
| try { | |
| const savedKey = localStorage.getItem(ONLINE_KEY_LS) || ""; | |
| if (savedKey) { | |
| onlineKey.value = savedKey; | |
| settings.online_key = savedKey; | |
| } | |
| const savedModel = localStorage.getItem(ONLINE_MODEL_LS) || ""; | |
| if (savedModel) settings.online_model = savedModel; | |
| } catch (_) {} | |
| // Pre-populate the model dropdown with free-tier-friendly defaults so a | |
| // model is always selectable, even before "Check key" runs (clicking | |
| // "Check key" then refines the list to exactly what your key can use). | |
| populateModels(["gemini-2.5-flash", "gemini-2.5-flash-lite"], "gemini-2.5-flash"); | |
| onlineToggle.addEventListener("change", () => { | |
| settings.online = onlineToggle.checked; | |
| onlineRow.hidden = !onlineToggle.checked; | |
| }); | |
| onlineKey.addEventListener("input", () => { | |
| settings.online_key = onlineKey.value.trim(); | |
| try { | |
| localStorage.setItem(ONLINE_KEY_LS, settings.online_key); | |
| } catch (_) {} | |
| setOnlineStatus("", ""); | |
| }); | |
| onlineModel.addEventListener("change", () => { | |
| settings.online_model = onlineModel.value; | |
| try { | |
| localStorage.setItem(ONLINE_MODEL_LS, settings.online_model); | |
| } catch (_) {} | |
| }); | |
| onlineCheck.addEventListener("click", checkOnlineKey); | |
| } | |
| function setOnlineStatus(msg, kind) { | |
| if (!onlineStatus) return; | |
| onlineStatus.textContent = msg; | |
| onlineStatus.className = "online-status" + (kind ? " " + kind : ""); | |
| } | |
| async function checkOnlineKey() { | |
| const key = (onlineKey.value || "").trim(); | |
| if (!key) { | |
| setOnlineStatus("Enter a key first.", "bad"); | |
| return; | |
| } | |
| onlineCheck.disabled = true; | |
| setOnlineStatus("Checking…", ""); | |
| const form = new FormData(); | |
| form.append("api_key", key); | |
| try { | |
| const res = await fetch("/api/online/validate", { method: "POST", body: form }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) { | |
| setOnlineStatus(data.detail ? shortErr(data.detail) : "Key check failed.", "bad"); | |
| return; | |
| } | |
| const models = data.supported && data.supported.length ? data.supported : []; | |
| populateModels(models, data.recommended); | |
| setOnlineStatus("✓ Key works · " + (models.length || "?") + " model(s)", "good"); | |
| } catch (e) { | |
| setOnlineStatus("Network error checking key.", "bad"); | |
| } finally { | |
| onlineCheck.disabled = false; | |
| } | |
| } | |
| function populateModels(models, recommended) { | |
| if (!onlineModel) return; | |
| onlineModel.innerHTML = ""; | |
| const list = models && models.length ? models : recommended ? [recommended] : []; | |
| list.forEach((m) => { | |
| const opt = document.createElement("option"); | |
| opt.value = m; | |
| opt.textContent = m; | |
| onlineModel.appendChild(opt); | |
| }); | |
| const saved = settings.online_model; | |
| const choose = | |
| saved && list.includes(saved) | |
| ? saved | |
| : recommended && list.includes(recommended) | |
| ? recommended | |
| : list[0] || ""; | |
| onlineModel.value = choose; | |
| settings.online_model = choose; | |
| try { | |
| localStorage.setItem(ONLINE_MODEL_LS, choose); | |
| } catch (_) {} | |
| } | |
| function shortErr(detail) { | |
| const s = String(detail); | |
| return s.length > 90 ? s.slice(0, 90) + "…" : s; | |
| } | |
| // ---------- Suggest best settings (opt-in) ---------- | |
| const suggestBtn = $("suggestBtn"); | |
| const suggestInput = $("suggestInput"); | |
| const suggestResult = $("suggestResult"); | |
| const suggestApply = $("suggestApply"); | |
| let lastRecommended = null; | |
| if (suggestBtn) { | |
| suggestBtn.addEventListener("click", () => suggestInput.click()); | |
| suggestInput.addEventListener("change", () => { | |
| const f = suggestInput.files && suggestInput.files[0]; | |
| suggestInput.value = ""; | |
| if (f) analyzeFile(f); | |
| }); | |
| suggestApply.addEventListener("click", () => { | |
| if (lastRecommended) applyRecommended(lastRecommended); | |
| }); | |
| } | |
| async function analyzeFile(file) { | |
| suggestBtn.disabled = true; | |
| suggestBtn.textContent = "Analyzing 1–2 pages…"; | |
| suggestResult.hidden = true; | |
| const form = new FormData(); | |
| form.append("file", file, file.name); | |
| form.append("lang", settings.lang); | |
| try { | |
| const res = await fetch("/api/analyze", { method: "POST", body: form }); | |
| if (!res.ok) { | |
| showToast("Analysis failed."); | |
| return; | |
| } | |
| const data = await res.json(); | |
| renderSuggestion(data); | |
| } catch (e) { | |
| showToast("Analysis failed (network)."); | |
| } finally { | |
| suggestBtn.disabled = false; | |
| suggestBtn.textContent = "Auto-detect best settings"; | |
| } | |
| } | |
| function renderSuggestion(data) { | |
| lastRecommended = data.recommended || null; | |
| const rat = suggestResult.querySelector('[data-role="suggestRationale"]'); | |
| const ev = suggestResult.querySelector('[data-role="suggestEvidence"]'); | |
| if (data.needs_ocr === false) { | |
| rat.textContent = data.rationale; | |
| ev.innerHTML = ""; | |
| suggestApply.hidden = true; // nothing to apply; defaults are fine | |
| } else { | |
| rat.textContent = data.rationale || ""; | |
| suggestApply.hidden = false; | |
| const rows = (data.evidence || []).map((r) => { | |
| const tag = r.recommended ? " ★" : ""; | |
| const conf = r.mean_conf == null ? "–" : Math.round(r.mean_conf * 100) + "%"; | |
| const row = document.createElement("div"); | |
| row.className = "ev-row" + (r.recommended ? " best" : ""); | |
| [r.name + tag, "score " + r.composite, "conf " + conf, "lines " + r.confident_lines] | |
| .forEach((t) => { | |
| const s = document.createElement("span"); | |
| s.textContent = t; | |
| row.appendChild(s); | |
| }); | |
| return row; | |
| }); | |
| ev.innerHTML = ""; | |
| rows.forEach((r) => ev.appendChild(r)); | |
| suggestApply.classList.toggle("btn-primary", data.decision === "decisive"); | |
| } | |
| suggestResult.hidden = false; | |
| showToast( | |
| data.decision === "decisive" | |
| ? "Found clearly better settings — review and Apply." | |
| : "Recommendation ready." | |
| ); | |
| } | |
| function applyRecommended(rec) { | |
| if (rec.mode) { | |
| settings.mode = rec.mode; | |
| modeToggle.querySelectorAll(".seg").forEach((b) => { | |
| const active = b.dataset.mode === rec.mode; | |
| b.classList.toggle("active", active); | |
| b.setAttribute("aria-checked", active ? "true" : "false"); | |
| }); | |
| } | |
| if (rec.lang) { | |
| settings.lang = rec.lang; | |
| langSelect.value = rec.lang; | |
| } | |
| settings.preprocess = !!rec.preprocess; | |
| preprocessToggle.checked = settings.preprocess; | |
| settings.binarize = !!rec.binarize; | |
| binarizeToggle.checked = settings.binarize; | |
| binarizeToggle.disabled = !settings.preprocess; | |
| showToast("Applied recommended settings. Now drop your PDF to extract."); | |
| } | |
| // ---------- Drag & drop + picker ---------- | |
| dropzone.addEventListener("click", () => fileInput.click()); | |
| dropzone.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| fileInput.click(); | |
| } | |
| }); | |
| fileInput.addEventListener("change", () => { | |
| handleFiles(fileInput.files); | |
| fileInput.value = ""; | |
| }); | |
| ["dragenter", "dragover"].forEach((ev) => | |
| dropzone.addEventListener(ev, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.add("dragover"); | |
| }) | |
| ); | |
| ["dragleave", "dragend"].forEach((ev) => | |
| dropzone.addEventListener(ev, (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove("dragover"); | |
| }) | |
| ); | |
| dropzone.addEventListener("drop", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| dropzone.classList.remove("dragover"); | |
| if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); | |
| }); | |
| // Prevent the browser from navigating when a file is dropped outside the zone. | |
| ["dragover", "drop"].forEach((ev) => | |
| window.addEventListener(ev, (e) => { | |
| if (!dropzone.contains(e.target)) e.preventDefault(); | |
| }) | |
| ); | |
| function handleFiles(fileList) { | |
| const files = Array.from(fileList).filter( | |
| (f) => f.type === "application/pdf" || /\.pdf$/i.test(f.name) | |
| ); | |
| if (!files.length) { | |
| showToast("Please choose PDF files only."); | |
| return; | |
| } | |
| files.forEach(uploadFile); | |
| } | |
| // ---------- Upload ---------- | |
| async function uploadFile(file) { | |
| const form = new FormData(); | |
| form.append("file", file, file.name); | |
| form.append("mode", settings.mode); | |
| form.append("lang", settings.lang); | |
| form.append("preprocess", settings.preprocess ? "true" : "false"); | |
| form.append("binarize", settings.binarize ? "true" : "false"); | |
| form.append("handwriting", settings.handwriting ? "true" : "false"); | |
| form.append("online", settings.online ? "true" : "false"); | |
| form.append("online_key", settings.online_key || ""); | |
| form.append("online_model", settings.online_model || ""); | |
| form.append("force_ocr", settings.force_ocr ? "true" : "false"); | |
| form.append("remove_headers", settings.remove_headers ? "true" : "false"); | |
| // Optimistic card while uploading. | |
| const placeholderId = "pending-" + Math.random().toString(36).slice(2); | |
| const job = createFileCard({ | |
| jobId: placeholderId, | |
| filename: file.name, | |
| totalPages: 0, | |
| status: "pending", | |
| }); | |
| setFileStatus(job, "pending", "Uploading…"); | |
| let res; | |
| try { | |
| res = await fetch("/api/upload", { method: "POST", body: form }); | |
| } catch (err) { | |
| failJob(job, "Network error during upload: " + (err && err.message ? err.message : err)); | |
| return; | |
| } | |
| if (!res.ok) { | |
| let detail = "Upload failed (" + res.status + ")"; | |
| try { | |
| const data = await res.json(); | |
| if (data && data.detail) detail = humanizeError(data.detail); | |
| } catch (_) {} | |
| failJob(job, detail); | |
| return; | |
| } | |
| let data; | |
| try { | |
| data = await res.json(); | |
| } catch (err) { | |
| failJob(job, "Invalid server response."); | |
| return; | |
| } | |
| // If the user removed this card while it was still uploading, don't | |
| // resurrect it: free the real server job (now that we know its id) and bail | |
| // out before re-adding state / opening a stream. | |
| if (job.removed) { | |
| const id = data && data.job_id; | |
| if (id) fetch("/api/jobs/" + encodeURIComponent(id), { method: "DELETE" }).catch(() => {}); | |
| return; | |
| } | |
| // Re-key the job from placeholder -> real job_id. | |
| rekeyJob(job, placeholderId, data.job_id); | |
| job.jobId = data.job_id; | |
| job.filename = data.filename || file.name; | |
| job.totalPages = data.total_pages || 0; | |
| job.status = data.status || "processing"; | |
| job.el.fileName.textContent = job.filename; | |
| setFileStatus(job, "processing", "Processing…"); | |
| updateProgress(job, 0, job.totalPages); | |
| openStream(job); | |
| } | |
| function rekeyJob(job, oldId, newId) { | |
| jobs.delete(oldId); | |
| jobs.set(newId, job); | |
| const idx = jobOrder.indexOf(oldId); | |
| if (idx !== -1) jobOrder[idx] = newId; | |
| } | |
| // ---------- SSE stream ---------- | |
| function openStream(job) { | |
| // Never (re)open a stream for a job the user removed or that's gone from | |
| // state — a pollOnce reopen could otherwise leak an untracked EventSource. | |
| if (job.removed || !jobs.has(job.jobId)) return; | |
| if (job.es) { | |
| job.es.close(); | |
| job.es = null; | |
| } | |
| const es = new EventSource("/api/jobs/" + encodeURIComponent(job.jobId) + "/stream"); | |
| job.es = es; | |
| es.onmessage = (evt) => { | |
| let payload; | |
| try { | |
| payload = JSON.parse(evt.data); | |
| } catch (_) { | |
| return; | |
| } | |
| handleEvent(job, payload); | |
| }; | |
| es.onerror = () => { | |
| // Network/stream dropped. If the job isn't terminal, fall back to polling once. | |
| if (["done", "error", "cancelled"].includes(job.status)) { | |
| es.close(); | |
| return; | |
| } | |
| es.close(); | |
| pollOnce(job); | |
| }; | |
| } | |
| function handleEvent(job, payload) { | |
| if (payload.type === "progress") { | |
| if (payload.page) applyPage(job, payload.page); | |
| updateProgress( | |
| job, | |
| typeof payload.processed === "number" ? payload.processed : countDone(job), | |
| typeof payload.total === "number" ? payload.total : job.totalPages | |
| ); | |
| } else if (payload.type === "done") { | |
| setFileStatus(job, "done", "Done"); | |
| closeStream(job); | |
| refreshExportState(); | |
| } else if (payload.type === "error") { | |
| failJob(job, payload.error ? humanizeError(payload.error) : "Processing failed."); | |
| closeStream(job); | |
| } else if (payload.type === "cancelled") { | |
| setFileStatus(job, "cancelled", "Cancelled"); | |
| closeStream(job); | |
| } | |
| } | |
| function closeStream(job) { | |
| if (job.es) { | |
| job.es.close(); | |
| job.es = null; | |
| } | |
| job.el.cancelBtn.disabled = true; | |
| } | |
| // Fallback: fetch the full job state once if the stream errored mid-flight. | |
| async function pollOnce(job) { | |
| // Bail if the user removed this card (or it's no longer tracked) so we don't | |
| // resurrect a removed job's stream or render into a detached DOM subtree. | |
| if (job.removed || !jobs.has(job.jobId)) return; | |
| try { | |
| const res = await fetch("/api/jobs/" + encodeURIComponent(job.jobId)); | |
| if (job.removed || !jobs.has(job.jobId)) return; | |
| if (!res.ok) { | |
| if (!["done", "error", "cancelled"].includes(job.status)) { | |
| failJob(job, "Lost connection to the job stream."); | |
| } | |
| return; | |
| } | |
| const data = await res.json(); | |
| job.totalPages = data.total_pages || job.totalPages; | |
| (data.pages || []).forEach((p) => { | |
| if (p.status === "done" || p.status === "error") applyPage(job, p); | |
| }); | |
| updateProgress(job, data.processed_pages || 0, job.totalPages); | |
| if (data.status === "done") setFileStatus(job, "done", "Done"); | |
| else if (data.status === "error") failJob(job, data.error ? humanizeError(data.error) : "Processing failed."); | |
| else if (data.status === "cancelled") setFileStatus(job, "cancelled", "Cancelled"); | |
| else { | |
| // still processing — reopen the stream | |
| openStream(job); | |
| } | |
| refreshExportState(); | |
| } catch (err) { | |
| if (!["done", "error", "cancelled"].includes(job.status)) { | |
| failJob(job, "Lost connection to the job stream."); | |
| } | |
| } | |
| } | |
| // ---------- Page rendering ---------- | |
| function applyPage(job, page) { | |
| const pageNo = page.page; | |
| let entry = job.pages.get(pageNo); | |
| if (!entry) { | |
| entry = buildPageCard(job, pageNo); | |
| job.pages.set(pageNo, entry); | |
| insertPageCardInOrder(job, entry); | |
| } | |
| entry.data = page; | |
| // badge | |
| const badge = entry.el.badge; | |
| badge.className = "badge"; | |
| if (page.status === "error") { | |
| badge.classList.add("error"); | |
| badge.textContent = "Error"; | |
| } else if (page.source === "text") { | |
| badge.classList.add("text"); | |
| badge.textContent = "Text layer"; | |
| } else if (page.source === "handwriting") { | |
| badge.classList.add("handwriting"); | |
| badge.textContent = "Handwriting"; | |
| badge.title = "Recognised with the local handwriting model (TrOCR)."; | |
| } else if (page.source === "online") { | |
| badge.classList.add("online"); | |
| badge.textContent = "Gemini"; | |
| badge.title = "Transcribed online by Google Gemini."; | |
| } else if (page.source === "ocr") { | |
| badge.classList.add("ocr"); | |
| const conf = typeof page.confidence === "number" ? page.confidence : null; | |
| if (conf !== null) { | |
| const pct = Math.round(conf * 100); | |
| badge.textContent = "OCR · " + pct + "%"; | |
| if (conf < LOWCONF_THRESH) { | |
| badge.classList.add("lowconf"); | |
| badge.title = "Low OCR confidence — verify this page against the original image."; | |
| } else { | |
| badge.title = "Average OCR confidence " + pct + "%"; | |
| } | |
| } else { | |
| badge.textContent = "OCR"; | |
| } | |
| } else { | |
| badge.classList.add("pending"); | |
| badge.textContent = "Pending"; | |
| } | |
| // text | |
| const text = page.status === "error" | |
| ? (page.error ? "Error: " + page.error : "Extraction failed for this page.") | |
| : (page.text || ""); | |
| // Preserve a user's manual edits even if the server re-broadcasts this page | |
| // (e.g. the header/footer cleanup pass at the end of a job). Also don't | |
| // clobber text while the editor is OPEN (unsaved) — the textarea holds the | |
| // user's in-progress value. | |
| if (!entry.edited && !entry.editing) entry.text = text; | |
| // "N to check": count low-confidence OCR lines (before any manual edit, and | |
| // not while the editor is open so the chip doesn't flicker back mid-edit). | |
| let lcCount = 0; | |
| if (!entry.edited && !entry.editing && page.status === "done" && Array.isArray(page.lines)) { | |
| lcCount = page.lines.filter( | |
| (l) => l && typeof l.confidence === "number" && l.confidence < LOWCONF_THRESH | |
| ).length; | |
| } | |
| if (entry.el.toCheck) { | |
| if (lcCount > 0) { | |
| entry.el.toCheck.hidden = false; | |
| entry.el.toCheck.textContent = "⚠ " + lcCount + " to check"; | |
| entry.el.toCheck.title = | |
| lcCount + " line(s) the OCR is unsure about — highlighted below. Click Edit to fix them."; | |
| } else { | |
| entry.el.toCheck.hidden = true; | |
| } | |
| } | |
| const q = searchInput.value.trim(); | |
| if (q) { | |
| // Re-run the whole search so the match count includes this newly arrived | |
| // / re-broadcast page (renderPageBody alone wouldn't update searchCount). | |
| runSearch(); | |
| } else { | |
| renderPageBody(entry, ""); | |
| } | |
| } | |
| function buildPageCard(job, pageNo) { | |
| const frag = pageCardTpl.content.cloneNode(true); | |
| const root = frag.querySelector('[data-role="pageCard"]'); | |
| const el = { | |
| pageNum: frag.querySelector('[data-role="pageNum"]'), | |
| badge: frag.querySelector('[data-role="badge"]'), | |
| toCheck: frag.querySelector('[data-role="toCheck"]'), | |
| pageText: frag.querySelector('[data-role="pageText"]'), | |
| pageEdit: frag.querySelector('[data-role="pageEdit"]'), | |
| editBtn: frag.querySelector('[data-role="editBtn"]'), | |
| toggleImg: frag.querySelector('[data-role="toggleImg"]'), | |
| copyPage: frag.querySelector('[data-role="copyPage"]'), | |
| imageWrap: frag.querySelector('[data-role="imageWrap"]'), | |
| }; | |
| el.pageNum.textContent = "Page " + pageNo; | |
| const entry = { pageNo, root, el, text: "", data: null, imgLoaded: false, edited: false, editing: false }; | |
| el.copyPage.addEventListener("click", () => { | |
| copyText(entry.text || ""); | |
| }); | |
| // Edit: swap the read-only text for a textarea; saving updates the page's | |
| // text so Copy and all exports use the corrected version. | |
| el.editBtn.addEventListener("click", () => { | |
| const editing = !el.pageEdit.hidden; | |
| if (!editing) { | |
| el.pageEdit.value = entry.text || ""; | |
| el.pageEdit.hidden = false; | |
| el.pageText.hidden = true; | |
| el.editBtn.textContent = "Done"; | |
| if (el.toCheck) el.toCheck.hidden = true; | |
| entry.editing = true; // a re-broadcast must not overwrite text / reshow the chip mid-edit | |
| el.pageEdit.focus(); | |
| } else { | |
| entry.text = el.pageEdit.value; | |
| entry.edited = true; // stop low-confidence highlighting; keep edits on re-broadcast | |
| entry.editing = false; | |
| el.pageEdit.hidden = true; | |
| el.pageText.hidden = false; | |
| el.editBtn.textContent = "Edit"; | |
| const q = searchInput.value.trim(); | |
| if (q) runSearch(); else renderPageBody(entry, ""); | |
| refreshExportState(); | |
| showToast("Saved — Copy and exports now use your edited text."); | |
| } | |
| }); | |
| el.toggleImg.addEventListener("click", () => { | |
| const wrap = el.imageWrap; | |
| const isHidden = wrap.hasAttribute("hidden"); | |
| if (isHidden) { | |
| wrap.removeAttribute("hidden"); | |
| el.toggleImg.textContent = "Hide original page"; | |
| if (!entry.imgLoaded) { | |
| entry.imgLoaded = true; | |
| wrap.innerHTML = '<div class="img-loading">Rendering original page…</div>'; | |
| const img = new Image(); | |
| img.alt = "Original page " + pageNo; | |
| img.onload = () => { | |
| wrap.innerHTML = ""; | |
| wrap.appendChild(img); | |
| }; | |
| img.onerror = () => { | |
| wrap.innerHTML = '<div class="img-loading">Could not render this page.</div>'; | |
| entry.imgLoaded = false; | |
| }; | |
| img.src = | |
| "/api/jobs/" + encodeURIComponent(job.jobId) + "/pages/" + pageNo + "/image?dpi=150"; | |
| } | |
| } else { | |
| wrap.setAttribute("hidden", ""); | |
| el.toggleImg.textContent = "Show original page"; | |
| } | |
| }); | |
| return entry; | |
| } | |
| function insertPageCardInOrder(job, entry) { | |
| const container = job.el.pages; | |
| // Insert keeping ascending page order. | |
| let inserted = false; | |
| const children = container.children; | |
| for (let i = 0; i < children.length; i++) { | |
| const otherNo = Number(children[i].dataset.pageNo); | |
| if (otherNo > entry.pageNo) { | |
| container.insertBefore(entry.root, children[i]); | |
| inserted = true; | |
| break; | |
| } | |
| } | |
| entry.root.dataset.pageNo = String(entry.pageNo); | |
| if (!inserted) container.appendChild(entry.root); | |
| } | |
| // ---------- File card ---------- | |
| function createFileCard(meta) { | |
| const frag = fileCardTpl.content.cloneNode(true); | |
| const el = { | |
| root: frag.querySelector(".file-card"), | |
| statusDot: frag.querySelector('[data-role="statusDot"]'), | |
| fileName: frag.querySelector('[data-role="fileName"]'), | |
| statusText: frag.querySelector('[data-role="statusText"]'), | |
| cancelBtn: frag.querySelector('[data-role="cancelBtn"]'), | |
| removeBtn: frag.querySelector('[data-role="removeBtn"]'), | |
| progressFill: frag.querySelector('[data-role="progressFill"]'), | |
| progressLabel: frag.querySelector('[data-role="progressLabel"]'), | |
| fileError: frag.querySelector('[data-role="fileError"]'), | |
| pages: frag.querySelector('[data-role="pages"]'), | |
| // Knowledge graph hooks (all optional; present only in newer markup). | |
| kgPanel: frag.querySelector('[data-role="kgPanel"]'), | |
| kgStatus: frag.querySelector('[data-role="kgStatus"]'), | |
| kgBuildBtn: frag.querySelector('[data-role="kgBuildBtn"]'), | |
| kgBuildNote: frag.querySelector('[data-role="kgBuildNote"]'), | |
| kgQueryWrap: frag.querySelector('[data-role="kgQueryWrap"]'), | |
| kgQueryInput: frag.querySelector('[data-role="kgQueryInput"]'), | |
| kgAskBtn: frag.querySelector('[data-role="kgAskBtn"]'), | |
| kgResults: frag.querySelector('[data-role="kgResults"]'), | |
| kgTools: frag.querySelector('[data-role="kgVizToggle"]'), | |
| kgVizToggle: frag.querySelector('[data-role="kgVizToggle"]'), | |
| kgExportBtn: frag.querySelector('[data-role="kgExportBtn"]'), | |
| kgRebuildBtn: frag.querySelector('[data-role="kgRebuildBtn"]'), | |
| kgVizWrap: frag.querySelector('[data-role="kgVizWrap"]'), | |
| kgCanvas: frag.querySelector('[data-role="kgCanvas"]'), | |
| kgVizNote: frag.querySelector('[data-role="kgVizNote"]'), | |
| }; | |
| el.fileName.textContent = meta.filename; | |
| const job = { | |
| jobId: meta.jobId, | |
| filename: meta.filename, | |
| totalPages: meta.totalPages || 0, | |
| status: meta.status || "pending", | |
| pages: new Map(), | |
| es: null, | |
| el, | |
| }; | |
| el.cancelBtn.addEventListener("click", () => cancelJob(job)); | |
| if (el.removeBtn) el.removeBtn.addEventListener("click", () => removeJob(job)); | |
| setupKnowledgeGraph(job); | |
| jobs.set(job.jobId, job); | |
| jobOrder.push(job.jobId); | |
| emptyState.hidden = true; | |
| toolbar.hidden = false; | |
| filesContainer.appendChild(el.root); | |
| return job; | |
| } | |
| // Remove a file card: stop its stream, free its memory on the server, and | |
| // drop it from the UI/state. Works at any stage (also cancels if in-flight). | |
| function removeJob(job) { | |
| job.removed = true; // upload continuation checks this to avoid resurrecting it | |
| closeStream(job); | |
| if (job.jobId && String(job.jobId).indexOf("pending-") !== 0) { | |
| // Tell the server to drop the job (frees its in-memory PDF bytes). | |
| fetch("/api/jobs/" + encodeURIComponent(job.jobId), { method: "DELETE" }).catch(() => {}); | |
| } | |
| if (job.el && job.el.root && job.el.root.parentNode) { | |
| job.el.root.parentNode.removeChild(job.el.root); | |
| } | |
| jobs.delete(job.jobId); | |
| const idx = jobOrder.indexOf(job.jobId); | |
| if (idx !== -1) jobOrder.splice(idx, 1); | |
| if (jobs.size === 0) { | |
| emptyState.hidden = false; | |
| toolbar.hidden = true; | |
| } | |
| refreshExportState(); | |
| // Removing a card changes the total match set. Re-run the active search so | |
| // the "N matches" chip doesn't stay stale (over-counted) — same as the | |
| // applyPage and edit-save paths already do. | |
| if (jobs.size > 0 && searchInput.value.trim()) runSearch(); | |
| } | |
| function setFileStatus(job, status, label) { | |
| job.status = status; | |
| job.el.statusText.textContent = label; | |
| job.el.statusDot.className = "status-dot " + status; | |
| if (status === "done" || status === "error" || status === "cancelled") { | |
| job.el.cancelBtn.disabled = true; | |
| } | |
| // Reveal the knowledge-graph panel once extraction has finished (there is | |
| // now text to build from). Only on a successful run. | |
| if (status === "done" && job.el && job.el.kgPanel) { | |
| job.el.kgPanel.hidden = false; | |
| } | |
| } | |
| function updateProgress(job, processed, total) { | |
| total = total || job.totalPages || 0; | |
| const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0; | |
| job.el.progressFill.style.width = pct + "%"; | |
| job.el.progressLabel.textContent = processed + " / " + total; | |
| } | |
| function failJob(job, message) { | |
| setFileStatus(job, "error", "Error"); | |
| job.el.fileError.hidden = false; | |
| job.el.fileError.textContent = message; | |
| closeStream(job); | |
| } | |
| function countDone(job) { | |
| let n = 0; | |
| job.pages.forEach((p) => { | |
| if (p.data && (p.data.status === "done" || p.data.status === "error")) n++; | |
| }); | |
| return n; | |
| } | |
| async function cancelJob(job) { | |
| if (["done", "error", "cancelled"].includes(job.status)) return; | |
| job.el.cancelBtn.disabled = true; | |
| setFileStatus(job, "processing", "Cancelling…"); | |
| try { | |
| await fetch("/api/jobs/" + encodeURIComponent(job.jobId) + "/cancel", { method: "POST" }); | |
| } catch (_) { | |
| showToast("Could not reach server to cancel."); | |
| } | |
| } | |
| // ---------- Search & highlight ---------- | |
| let searchTimer = null; | |
| searchInput.addEventListener("input", () => { | |
| clearTimeout(searchTimer); | |
| searchTimer = setTimeout(runSearch, 120); | |
| }); | |
| function runSearch() { | |
| const q = searchInput.value.trim(); | |
| let total = 0; | |
| jobs.forEach((job) => { | |
| job.pages.forEach((entry) => { | |
| total += applySearchToEntry(entry, q); | |
| }); | |
| }); | |
| if (q) { | |
| searchCount.hidden = false; | |
| searchCount.textContent = total + (total === 1 ? " match" : " matches"); | |
| } else { | |
| searchCount.hidden = true; | |
| } | |
| } | |
| // Render a page's text into its <pre>, applying BOTH low-confidence line | |
| // highlighting (OCR pages, before the user edits) AND search highlighting. | |
| // Returns the number of search matches. | |
| function renderPageBody(entry, q) { | |
| const target = entry.el.pageText; | |
| const text = entry.text || ""; | |
| // Keep the element truly :empty for blank pages so the CSS | |
| // "(no text on this page)" placeholder shows (an empty text node would | |
| // defeat :empty). | |
| if (!text) { | |
| target.textContent = ""; | |
| return 0; | |
| } | |
| const ql = (q || "").toLowerCase(); | |
| const lines = | |
| !entry.edited && entry.data && entry.data.status === "done" && | |
| Array.isArray(entry.data.lines) | |
| ? entry.data.lines | |
| : null; | |
| const textLines = text.split("\n"); | |
| const frag = document.createDocumentFragment(); | |
| let count = 0; | |
| textLines.forEach((ln, i) => { | |
| const lc = | |
| lines && lines[i] && typeof lines[i].confidence === "number" && | |
| lines[i].confidence < LOWCONF_THRESH; | |
| let wrap = null; | |
| let sink = frag; | |
| if (lc) { | |
| wrap = document.createElement("span"); | |
| wrap.className = "lc-line"; | |
| wrap.title = | |
| "Low confidence (" + Math.round(lines[i].confidence * 100) + | |
| "%) — double-check this line"; | |
| sink = wrap; | |
| } | |
| const lower = ln.toLowerCase(); | |
| if (ql && lower.length === ln.length && lower.includes(ql)) { | |
| // Common case: lowercase preserves length, so indices in `lower` align | |
| // 1:1 with `ln`. Use ql.length consistently for slice + advance. | |
| let idx = 0; | |
| while (true) { | |
| const found = lower.indexOf(ql, idx); | |
| if (found === -1) { | |
| sink.appendChild(document.createTextNode(ln.slice(idx))); | |
| break; | |
| } | |
| if (found > idx) sink.appendChild(document.createTextNode(ln.slice(idx, found))); | |
| const mark = document.createElement("mark"); | |
| mark.textContent = ln.slice(found, found + ql.length); | |
| sink.appendChild(mark); | |
| count++; | |
| idx = found + ql.length; | |
| } | |
| } else if (ql && lower.includes(ql)) { | |
| // Rare: a character whose lowercase changes length (e.g. Turkish 'İ') | |
| // makes `lower` indices misalign with `ln`. Count the matches but render | |
| // the line plain rather than wrapping the wrong characters in <mark>. | |
| let from = 0, f; | |
| while ((f = lower.indexOf(ql, from)) !== -1) { count++; from = f + ql.length; } | |
| sink.appendChild(document.createTextNode(ln)); | |
| } else { | |
| sink.appendChild(document.createTextNode(ln)); | |
| } | |
| if (wrap) frag.appendChild(wrap); | |
| if (i < textLines.length - 1) frag.appendChild(document.createTextNode("\n")); | |
| }); | |
| target.textContent = ""; | |
| target.appendChild(frag); | |
| return count; | |
| } | |
| function applySearchToEntry(entry, q) { | |
| return renderPageBody(entry, q); | |
| } | |
| // ---------- Copy ---------- | |
| copyAllBtn.addEventListener("click", () => { | |
| const parts = []; | |
| orderedJobs().forEach((job) => { | |
| parts.push("===== " + job.filename + " ====="); | |
| orderedPages(job).forEach((entry) => { | |
| parts.push("--- Page " + entry.pageNo + " ---"); | |
| parts.push(entry.text || ""); | |
| }); | |
| parts.push(""); | |
| }); | |
| copyText(parts.join("\n")); | |
| }); | |
| function copyText(text) { | |
| if (navigator.clipboard && navigator.clipboard.writeText) { | |
| navigator.clipboard.writeText(text).then( | |
| () => showToast("Copied to clipboard"), | |
| () => fallbackCopy(text) | |
| ); | |
| } else { | |
| fallbackCopy(text); | |
| } | |
| } | |
| function fallbackCopy(text) { | |
| const ta = document.createElement("textarea"); | |
| ta.value = text; | |
| ta.style.position = "fixed"; | |
| ta.style.opacity = "0"; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| try { | |
| document.execCommand("copy"); | |
| showToast("Copied to clipboard"); | |
| } catch (_) { | |
| showToast("Copy failed"); | |
| } | |
| document.body.removeChild(ta); | |
| } | |
| // ---------- Export ---------- | |
| document.querySelectorAll("[data-export]").forEach((btn) => { | |
| btn.addEventListener("click", () => exportAll(btn.dataset.export)); | |
| }); | |
| function exportAll(kind) { | |
| const list = orderedJobs(); | |
| if (!list.length) { | |
| showToast("Nothing to export yet."); | |
| return; | |
| } | |
| if (kind === "docx") { | |
| return exportDocx(list); | |
| } | |
| if (kind === "json") { | |
| const data = list.map((job) => ({ | |
| filename: job.filename, | |
| job_id: String(job.jobId), | |
| status: job.status, | |
| total_pages: job.totalPages, | |
| pages: orderedPages(job).map((entry) => ({ | |
| page: entry.pageNo, | |
| source: entry.data ? entry.data.source : null, | |
| status: entry.data ? entry.data.status : "pending", | |
| confidence: entry.data ? entry.data.confidence : null, | |
| text: entry.text || "", | |
| })), | |
| })); | |
| download("extraction.json", JSON.stringify(data, null, 2), "application/json"); | |
| return; | |
| } | |
| if (kind === "md") { | |
| const parts = []; | |
| list.forEach((job) => { | |
| parts.push("# " + job.filename + "\n"); | |
| orderedPages(job).forEach((entry) => { | |
| parts.push("## Page " + entry.pageNo + "\n"); | |
| parts.push((entry.text || "_(no text)_") + "\n"); | |
| }); | |
| }); | |
| download(baseName(list) + ".md", parts.join("\n"), "text/markdown"); | |
| return; | |
| } | |
| // txt | |
| const parts = []; | |
| list.forEach((job) => { | |
| parts.push("===== " + job.filename + " =====\n"); | |
| orderedPages(job).forEach((entry) => { | |
| parts.push("--- Page " + entry.pageNo + " ---"); | |
| parts.push((entry.text || "") + "\n"); | |
| }); | |
| }); | |
| download(baseName(list) + ".txt", parts.join("\n"), "text/plain"); | |
| } | |
| async function exportDocx(list) { | |
| const documents = list.map((job) => ({ | |
| filename: job.filename, | |
| pages: orderedPages(job).map((entry) => ({ | |
| page: entry.pageNo, | |
| source: entry.data ? entry.data.source : null, | |
| confidence: entry.data ? entry.data.confidence : null, | |
| text: entry.text || "", | |
| })), | |
| })); | |
| try { | |
| const res = await fetch("/api/export/docx", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ documents }), | |
| }); | |
| if (!res.ok) { | |
| showToast("Word export failed."); | |
| return; | |
| } | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = (list.length === 1 ? baseName(list) : "extraction") + ".docx"; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| showToast("Word document downloaded"); | |
| } catch (e) { | |
| showToast("Word export failed."); | |
| } | |
| } | |
| function baseName(list) { | |
| if (list.length === 1) { | |
| return list[0].filename.replace(/\.pdf$/i, "") || "extraction"; | |
| } | |
| return "extraction"; | |
| } | |
| function download(filename, content, mime) { | |
| const blob = new Blob([content], { type: mime + ";charset=utf-8" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } | |
| // ---------- Ordering helpers ---------- | |
| function orderedJobs() { | |
| const out = []; | |
| jobOrder.forEach((id) => { | |
| const j = jobs.get(id); | |
| if (j) out.push(j); | |
| }); | |
| return out; | |
| } | |
| function orderedPages(job) { | |
| return Array.from(job.pages.values()).sort((a, b) => a.pageNo - b.pageNo); | |
| } | |
| function refreshExportState() { | |
| // Export/copy always available once there is at least one job; nothing to gate. | |
| } | |
| // ---------- Knowledge graph (opt-in, online) ---------- | |
| // Colors per entity type; chosen to read on both light & dark themes. Used | |
| // for the result-card dots/chips AND the canvas nodes so the two views match. | |
| const KG_TYPE_COLORS = { | |
| PERSON: "#7c3aed", ORG: "#0ea5b7", PLACE: "#0e9f6e", DATE: "#c2790f", | |
| ID: "#e0455c", AMOUNT: "#2563eb", CONCEPT: "#9333ea", EVENT: "#db2777", | |
| OTHER: "#8a8a99", | |
| }; | |
| function kgTypeColor(t) { | |
| return KG_TYPE_COLORS[String(t || "OTHER").toUpperCase()] || KG_TYPE_COLORS.OTHER; | |
| } | |
| function setupKnowledgeGraph(job) { | |
| const el = job.el; | |
| if (!el || !el.kgPanel) return; // older markup without the KG panel | |
| job.kgBuilt = false; | |
| job.kgViz = null; | |
| job.kgLaidOut = false; | |
| if (el.kgBuildBtn) el.kgBuildBtn.addEventListener("click", () => buildKG(job)); | |
| if (el.kgRebuildBtn) el.kgRebuildBtn.addEventListener("click", () => buildKG(job)); | |
| if (el.kgAskBtn) el.kgAskBtn.addEventListener("click", () => askKG(job)); | |
| if (el.kgQueryInput) { | |
| el.kgQueryInput.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter") { e.preventDefault(); askKG(job); } | |
| }); | |
| } | |
| if (el.kgExportBtn) el.kgExportBtn.addEventListener("click", () => exportKG(job)); | |
| if (el.kgVizToggle) el.kgVizToggle.addEventListener("click", () => toggleKGViz(job)); | |
| } | |
| function setKgStatus(job, msg, kind) { | |
| const s = job.el.kgStatus; | |
| if (!s) return; | |
| s.textContent = msg || ""; | |
| s.className = "kg-summary-status" + (kind ? " " + kind : ""); | |
| } | |
| async function buildKG(job) { | |
| const el = job.el; | |
| el.kgPanel.open = true; | |
| if (el.kgBuildBtn) el.kgBuildBtn.disabled = true; | |
| if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = true; | |
| setKgStatus(job, "Building…", "busy"); | |
| el.kgBuildNote.textContent = "Reading entities & facts with Gemini — this can take a moment on a long document…"; | |
| const form = new FormData(); | |
| form.append("job_id", job.jobId); | |
| form.append("online_key", settings.online_key || ""); | |
| form.append("online_model", settings.online_model || ""); | |
| try { | |
| const res = await fetch("/api/graph/build", { method: "POST", body: form }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) { | |
| if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; } | |
| if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false; | |
| setKgStatus(job, "Failed", "bad"); | |
| el.kgBuildNote.textContent = data.detail ? humanizeError(data.detail) : "Build failed."; | |
| return; | |
| } | |
| job.kgBuilt = true; | |
| job.kgViz = null; | |
| job.kgLaidOut = false; | |
| if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false; | |
| // Hide any previously open viz so it re-fetches the new graph next open. | |
| if (el.kgVizWrap) el.kgVizWrap.hidden = true; | |
| if (el.kgVizToggle) el.kgVizToggle.textContent = "Show graph view"; | |
| if (!data.nodes) { | |
| setKgStatus(job, "No entities found", ""); | |
| el.kgBuildNote.textContent = | |
| "Gemini found no extractable entities/facts in this document's text."; | |
| if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; el.kgBuildBtn.textContent = "Try again"; } | |
| el.kgQueryWrap.hidden = true; | |
| return; | |
| } | |
| setKgStatus(job, data.nodes + " entities · " + data.edges + " facts", "good"); | |
| let note = data.has_vectors | |
| ? "Graph ready — ask a question below (semantic + graph search)." | |
| : "Graph ready — ask a question below (keyword graph search)."; | |
| if (data.pages_failed) { | |
| note += " " + data.pages_failed + " page(s) were skipped (rate limit or block)."; | |
| } | |
| if (data.embed_error) { | |
| note += " Semantic search unavailable (" + shortErr(data.embed_error) + | |
| ") — using keyword search."; | |
| } | |
| el.kgBuildNote.textContent = note; | |
| if (el.kgBuildBtn) el.kgBuildBtn.hidden = true; | |
| el.kgQueryWrap.hidden = false; | |
| showToast("Knowledge graph: " + data.nodes + " entities, " + data.edges + " facts"); | |
| if (el.kgQueryInput) el.kgQueryInput.focus(); | |
| } catch (e) { | |
| if (el.kgBuildBtn) { el.kgBuildBtn.disabled = false; el.kgBuildBtn.hidden = false; } | |
| if (el.kgRebuildBtn) el.kgRebuildBtn.disabled = false; | |
| setKgStatus(job, "Failed", "bad"); | |
| el.kgBuildNote.textContent = "Network error during build."; | |
| } | |
| } | |
| async function askKG(job) { | |
| const el = job.el; | |
| const q = (el.kgQueryInput.value || "").trim(); | |
| if (!q) return; | |
| el.kgAskBtn.disabled = true; | |
| el.kgResults.innerHTML = ""; | |
| const loading = document.createElement("p"); | |
| loading.className = "kg-empty"; | |
| loading.textContent = "Searching…"; | |
| el.kgResults.appendChild(loading); | |
| const form = new FormData(); | |
| form.append("job_id", job.jobId); | |
| form.append("query", q); | |
| form.append("online_key", settings.online_key || ""); | |
| try { | |
| const res = await fetch("/api/graph/query", { method: "POST", body: form }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) { | |
| renderKgMessage(job, data.detail ? humanizeError(data.detail) : "Query failed."); | |
| return; | |
| } | |
| renderKGResults(job, data); | |
| } catch (e) { | |
| renderKgMessage(job, "Network error during search."); | |
| } finally { | |
| el.kgAskBtn.disabled = false; | |
| } | |
| } | |
| function renderKgMessage(job, msg) { | |
| const c = job.el.kgResults; | |
| c.innerHTML = ""; | |
| const p = document.createElement("p"); | |
| p.className = "kg-empty"; | |
| p.textContent = msg; | |
| c.appendChild(p); | |
| } | |
| // Build a "p.1, 2" provenance chip element. | |
| function kgPageTag(pages) { | |
| const tag = document.createElement("span"); | |
| tag.className = "kg-pages"; | |
| const list = (pages || []).filter((p) => p != null); | |
| tag.textContent = list.length | |
| ? (list.length === 1 ? "p." + list[0] : "p." + list.join(", ")) | |
| : ""; | |
| if (!list.length) tag.hidden = true; | |
| return tag; | |
| } | |
| // Render one triple as "subject → predicate → object" with a page tag. | |
| function kgTripleRow(t) { | |
| const row = document.createElement("div"); | |
| row.className = "kg-triple"; | |
| const s = document.createElement("span"); s.className = "kg-subj"; s.textContent = t.subject; | |
| const p = document.createElement("span"); p.className = "kg-pred"; p.textContent = t.predicate; | |
| const o = document.createElement("span"); o.className = "kg-obj"; o.textContent = t.object; | |
| const a1 = document.createElement("span"); a1.className = "kg-arrow"; a1.textContent = "→"; | |
| const a2 = document.createElement("span"); a2.className = "kg-arrow"; a2.textContent = "→"; | |
| row.appendChild(s); row.appendChild(a1); row.appendChild(p); row.appendChild(a2); row.appendChild(o); | |
| if (t.page != null) { | |
| const pg = document.createElement("span"); | |
| pg.className = "kg-pages kg-triple-page"; | |
| pg.textContent = "p." + t.page; | |
| row.appendChild(pg); | |
| } | |
| return row; | |
| } | |
| function renderKGResults(job, data) { | |
| const c = job.el.kgResults; | |
| c.innerHTML = ""; | |
| const meta = document.createElement("div"); | |
| meta.className = "kg-resultmeta"; | |
| const modeTxt = data.mode === "semantic" ? "semantic + graph" : "graph (lexical)"; | |
| meta.textContent = (data.answers || []).length | |
| ? "Top matches · " + modeTxt + " search" | |
| : ""; | |
| if (meta.textContent) c.appendChild(meta); | |
| if (!data.answers || !data.answers.length) { | |
| renderKgMessage(job, "No matching entities found. Try different words."); | |
| // keep the meta line removed for a clean empty state | |
| return; | |
| } | |
| data.answers.forEach((a) => { | |
| const card = document.createElement("div"); | |
| card.className = "kg-answer"; | |
| const head = document.createElement("div"); | |
| head.className = "kg-answer-head"; | |
| const dot = document.createElement("span"); | |
| dot.className = "kg-type-dot"; | |
| dot.style.background = kgTypeColor(a.type); | |
| const name = document.createElement("span"); | |
| name.className = "kg-entity"; | |
| name.textContent = a.entity; | |
| const chip = document.createElement("span"); | |
| chip.className = "kg-type-chip"; | |
| chip.textContent = (a.type || "OTHER").toLowerCase(); | |
| chip.style.color = kgTypeColor(a.type); | |
| head.appendChild(dot); | |
| head.appendChild(name); | |
| head.appendChild(chip); | |
| head.appendChild(kgPageTag(a.pages)); | |
| card.appendChild(head); | |
| // Supporting facts (the entity's own triples) — the explainable evidence. | |
| const facts = (a.facts && a.facts.length) ? a.facts : a.path; | |
| if (facts && facts.length) { | |
| const fwrap = document.createElement("div"); | |
| fwrap.className = "kg-facts"; | |
| facts.slice(0, 6).forEach((t) => fwrap.appendChild(kgTripleRow(t))); | |
| card.appendChild(fwrap); | |
| } | |
| // If reached via traversal, show the connecting chain from the query seed. | |
| if (a.hops > 0 && a.path && a.path.length) { | |
| const link = document.createElement("div"); | |
| link.className = "kg-linkpath"; | |
| const lbl = document.createElement("span"); | |
| lbl.className = "kg-link-label"; | |
| lbl.textContent = "linked to your query via:"; | |
| link.appendChild(lbl); | |
| a.path.forEach((t) => link.appendChild(kgTripleRow(t))); | |
| card.appendChild(link); | |
| } | |
| c.appendChild(card); | |
| }); | |
| } | |
| // ----- Graph visualization (vanilla canvas; no external libs) ----- | |
| async function toggleKGViz(job) { | |
| const el = job.el; | |
| if (!el.kgVizWrap) return; | |
| if (!el.kgVizWrap.hidden) { | |
| el.kgVizWrap.hidden = true; | |
| el.kgVizToggle.textContent = "Show graph view"; | |
| return; | |
| } | |
| el.kgVizWrap.hidden = false; | |
| el.kgVizToggle.textContent = "Hide graph view"; | |
| if (!job.kgViz) { | |
| el.kgVizNote.textContent = "Loading graph…"; | |
| try { | |
| const res = await fetch("/api/graph/" + encodeURIComponent(job.jobId)); | |
| const data = await res.json(); | |
| if (!res.ok || !data.nodes) { el.kgVizNote.textContent = "No graph to show."; return; } | |
| job.kgViz = data; | |
| job.kgLaidOut = false; | |
| } catch (e) { | |
| el.kgVizNote.textContent = "Could not load the graph."; | |
| return; | |
| } | |
| } | |
| drawKGViz(job); | |
| } | |
| function layoutKG(viz) { | |
| // Force-directed layout (Fruchterman–Reingold-ish) in a virtual unit box, | |
| // deterministic init so the picture is stable across redraws. | |
| const nodes = viz.nodes.map((n, i) => { | |
| const ang = (2 * Math.PI * i) / Math.max(1, viz.nodes.length); | |
| return { id: n.id, x: 0.5 + 0.35 * Math.cos(ang), y: 0.5 + 0.35 * Math.sin(ang), | |
| vx: 0, vy: 0, deg: n.degree || 0, name: n.name, type: n.type }; | |
| }); | |
| const index = new Map(nodes.map((n) => [n.id, n])); | |
| const edges = viz.edges | |
| .map((e) => [index.get(e.source), index.get(e.target)]) | |
| .filter((p) => p[0] && p[1]); | |
| const n = nodes.length || 1; | |
| const k = 0.9 / Math.sqrt(n); // ideal edge length | |
| let temp = 0.10; | |
| const ITER = 220; | |
| for (let it = 0; it < ITER; it++) { | |
| // repulsion (O(n^2); fine for the capped node count) | |
| for (let i = 0; i < nodes.length; i++) { | |
| let fx = 0, fy = 0; | |
| const a = nodes[i]; | |
| for (let j = 0; j < nodes.length; j++) { | |
| if (i === j) continue; | |
| const b = nodes[j]; | |
| let dx = a.x - b.x, dy = a.y - b.y; | |
| let d2 = dx * dx + dy * dy; | |
| if (d2 < 1e-6) { dx = (i - j) * 1e-3; dy = 1e-3; d2 = 1e-6; } | |
| const f = (k * k) / d2; | |
| fx += dx * f; fy += dy * f; | |
| } | |
| // gravity toward center keeps disconnected nodes on-canvas | |
| fx += (0.5 - a.x) * 0.02; | |
| fy += (0.5 - a.y) * 0.02; | |
| a.vx = fx; a.vy = fy; | |
| } | |
| // attraction along edges | |
| edges.forEach(([a, b]) => { | |
| let dx = a.x - b.x, dy = a.y - b.y; | |
| const d = Math.sqrt(dx * dx + dy * dy) || 1e-4; | |
| const f = (d * d) / k; | |
| const ux = (dx / d) * f, uy = (dy / d) * f; | |
| a.vx -= ux; a.vy -= uy; | |
| b.vx += ux; b.vy += uy; | |
| }); | |
| // integrate with cooling + clamp into the box | |
| nodes.forEach((a) => { | |
| const sp = Math.sqrt(a.vx * a.vx + a.vy * a.vy) || 1e-6; | |
| const step = Math.min(sp, temp) / sp; | |
| a.x = Math.max(0.04, Math.min(0.96, a.x + a.vx * step)); | |
| a.y = Math.max(0.05, Math.min(0.95, a.y + a.vy * step)); | |
| }); | |
| temp = Math.max(0.008, temp * 0.985); | |
| } | |
| return { nodes, edges }; | |
| } | |
| function drawKGViz(job) { | |
| const el = job.el; | |
| const canvas = el.kgCanvas; | |
| if (!canvas) return; | |
| if (!job.kgLaidOut) { | |
| job.kgLayout = layoutKG(job.kgViz); | |
| job.kgLaidOut = true; | |
| } | |
| const layout = job.kgLayout; | |
| const cssW = Math.max(280, canvas.clientWidth || canvas.parentElement.clientWidth || 640); | |
| const cssH = 400; | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = Math.round(cssW * dpr); | |
| canvas.height = Math.round(cssH * dpr); | |
| canvas.style.height = cssH + "px"; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.clearRect(0, 0, cssW, cssH); | |
| const cs = getComputedStyle(canvas); | |
| const textColor = cs.getPropertyValue("--text").trim() || "#222"; | |
| const edgeColor = cs.getPropertyValue("--border-strong").trim() || "#ccc"; | |
| const pad = 26; | |
| const X = (nx) => pad + nx * (cssW - 2 * pad); | |
| const Y = (ny) => pad + ny * (cssH - 2 * pad); | |
| // edges | |
| ctx.lineWidth = 1; | |
| ctx.strokeStyle = edgeColor; | |
| ctx.globalAlpha = 0.55; | |
| layout.edges.forEach(([a, b]) => { | |
| ctx.beginPath(); | |
| ctx.moveTo(X(a.x), Y(a.y)); | |
| ctx.lineTo(X(b.x), Y(b.y)); | |
| ctx.stroke(); | |
| }); | |
| ctx.globalAlpha = 1; | |
| // label only the most-connected nodes to avoid clutter | |
| const labelled = new Set( | |
| layout.nodes.slice().sort((a, b) => b.deg - a.deg).slice(0, 26).map((n) => n.id) | |
| ); | |
| ctx.font = "11px " + (cs.getPropertyValue("--sans").trim() || "sans-serif"); | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "top"; | |
| layout.nodes.forEach((nd) => { | |
| const r = 4 + Math.min(7, nd.deg * 1.4); | |
| ctx.beginPath(); | |
| ctx.arc(X(nd.x), Y(nd.y), r, 0, 2 * Math.PI); | |
| ctx.fillStyle = kgTypeColor(nd.type); | |
| ctx.fill(); | |
| ctx.lineWidth = 1.5; | |
| ctx.strokeStyle = "rgba(255,255,255,0.55)"; | |
| ctx.stroke(); | |
| if (labelled.has(nd.id)) { | |
| const label = nd.name.length > 22 ? nd.name.slice(0, 21) + "…" : nd.name; | |
| ctx.fillStyle = textColor; | |
| ctx.fillText(label, X(nd.x), Y(nd.y) + r + 2); | |
| } | |
| }); | |
| const more = job.kgViz.truncated | |
| ? " · showing top " + job.kgViz.nodes.length + " of " + job.kgViz.total_nodes | |
| : ""; | |
| el.kgVizNote.textContent = | |
| job.kgViz.total_nodes + " entities · " + job.kgViz.total_edges + " facts" + more + | |
| " · labels on the most-connected entities"; | |
| } | |
| async function exportKG(job) { | |
| try { | |
| const res = await fetch("/api/graph/" + encodeURIComponent(job.jobId) + "?full=1"); | |
| if (!res.ok) { showToast("Could not export the graph."); return; } | |
| const data = await res.json(); | |
| const payload = { | |
| filename: job.filename, | |
| node_count: data.node_count, | |
| triple_count: data.triple_count, | |
| embed_model: data.embed_model, | |
| entities: data.entities, | |
| triples: data.triples, | |
| }; | |
| download(baseName([job]) + ".kg.json", JSON.stringify(payload, null, 2), "application/json"); | |
| showToast("Knowledge graph exported"); | |
| } catch (e) { | |
| showToast("Could not export the graph."); | |
| } | |
| } | |
| // ---------- Misc ---------- | |
| function humanizeError(detail) { | |
| const d = String(detail).toLowerCase(); | |
| if (d.includes("encrypt") || d.includes("password") || d.includes("needs_pass")) { | |
| return "This PDF is encrypted / password-protected and cannot be processed."; | |
| } | |
| if (d.includes("corrupt") || d.includes("cannot open") || d.includes("damaged")) { | |
| return "This file appears to be corrupt or is not a valid PDF."; | |
| } | |
| return String(detail); | |
| } | |
| let toastTimer = null; | |
| function showToast(msg) { | |
| toast.textContent = msg; | |
| toast.hidden = false; | |
| // force reflow so the transition runs | |
| void toast.offsetWidth; | |
| toast.classList.add("show"); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => { | |
| toast.classList.remove("show"); | |
| setTimeout(() => { | |
| toast.hidden = true; | |
| }, 220); | |
| }, 1800); | |
| } | |
| })(); | |