Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Peer Reviewer</title> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| font-family: Georgia, "Times New Roman", serif; | |
| line-height: 1.5; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| padding: 24px; | |
| background: #f3f0ea; | |
| color: #111111; | |
| } | |
| main { | |
| width: min(860px, 100%); | |
| margin: 0 auto; | |
| } | |
| .intro { | |
| margin-bottom: 18px; | |
| } | |
| .intro h1 { | |
| margin: 0 0 8px; | |
| font-size: clamp(2rem, 4vw, 3rem); | |
| line-height: 1; | |
| font-weight: 700; | |
| } | |
| .intro p { | |
| margin: 0; | |
| max-width: 70ch; | |
| color: #2f2f2f; | |
| } | |
| textarea, | |
| button, | |
| .card { | |
| width: 100%; | |
| border: 1px solid #2c2c2c; | |
| border-radius: 10px; | |
| background: #ffffff; | |
| color: inherit; | |
| font: inherit; | |
| } | |
| textarea { | |
| min-height: 340px; | |
| padding: 16px; | |
| resize: vertical; | |
| } | |
| .actions { | |
| display: flex; | |
| gap: 12px; | |
| margin: 12px 0; | |
| } | |
| .actions button { | |
| padding: 12px 16px; | |
| cursor: pointer; | |
| background: #ffffff; | |
| transition: background 0.15s ease; | |
| } | |
| .actions button:hover:enabled { | |
| background: #ece7dd; | |
| } | |
| .actions button:disabled { | |
| cursor: wait; | |
| opacity: 0.7; | |
| } | |
| .card { | |
| min-height: 180px; | |
| padding: 16px; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .card.hidden { | |
| display: none; | |
| } | |
| .card.error { | |
| border-color: #8f2d2d; | |
| background: #fff3f1; | |
| } | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border: 0; | |
| } | |
| @media (max-width: 640px) { | |
| body { | |
| padding: 16px; | |
| } | |
| .actions { | |
| flex-direction: column; | |
| } | |
| } | |
| </style> | |
| <script | |
| defer | |
| src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" | |
| ></script> | |
| <script defer> | |
| const WORKER_URL = | |
| "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; | |
| const FALLBACK_API_BASE = "http://127.0.0.1:3000"; | |
| let resolvedApiBasePromise; | |
| function escapeHtml(text) { | |
| return text.replace(/[&<>"']/g, (character) => { | |
| const entities = { | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| '"': """, | |
| "'": "'", | |
| }; | |
| return entities[character]; | |
| }); | |
| } | |
| function renderOutput(outputCard, text, isError) { | |
| outputCard.classList.remove("hidden"); | |
| outputCard.classList.toggle("error", Boolean(isError)); | |
| if (isError) { | |
| outputCard.textContent = text; | |
| return; | |
| } | |
| const formatted = escapeHtml(text.trim()) | |
| .replace( | |
| /^(CLAIM|KEY NUMBER|ASSUMPTION|GAP|CITE CHECK):/gm, | |
| "<strong>$1:</strong>" | |
| ) | |
| .replace(/\n/g, "<br>"); | |
| outputCard.innerHTML = formatted; | |
| } | |
| function setBusy(isBusy, reviewButton, uploadButton, label) { | |
| reviewButton.disabled = isBusy; | |
| uploadButton.disabled = isBusy; | |
| reviewButton.textContent = label || "Review"; | |
| } | |
| function isLocalPreviewHost() { | |
| return ["127.0.0.1", "localhost"].includes(window.location.hostname); | |
| } | |
| async function isHealthy(baseUrl) { | |
| try { | |
| const response = await fetch(`${baseUrl}/health`); | |
| return response.ok; | |
| } catch (error) { | |
| void error; | |
| return false; | |
| } | |
| } | |
| async function getApiBase() { | |
| if (!resolvedApiBasePromise) { | |
| resolvedApiBasePromise = (async () => { | |
| const sameOriginHealthy = await isHealthy(""); | |
| if (sameOriginHealthy) { | |
| return ""; | |
| } | |
| if (isLocalPreviewHost() && window.location.origin !== FALLBACK_API_BASE) { | |
| const fallbackHealthy = await isHealthy(FALLBACK_API_BASE); | |
| if (fallbackHealthy) { | |
| return FALLBACK_API_BASE; | |
| } | |
| } | |
| return ""; | |
| })(); | |
| } | |
| return resolvedApiBasePromise; | |
| } | |
| function formatReviewError(status, payload, apiBase) { | |
| if (payload && payload.error) { | |
| return payload.error; | |
| } | |
| if (status === 405 && !apiBase) { | |
| return "This page is being served by a static preview server. Run npm start and open http://127.0.0.1:3000, or keep this preview open and run the backend separately on port 3000."; | |
| } | |
| return `Review failed with status ${status}.`; | |
| } | |
| async function requestReview(paperText) { | |
| const apiBase = await getApiBase(); | |
| const response = await fetch(`${apiBase}/api/review`, { | |
| method: "POST", | |
| headers: { | |
| "content-type": "application/json", | |
| }, | |
| body: JSON.stringify({ paperText }), | |
| }); | |
| const payload = await response.json().catch(() => ({})); | |
| if (!response.ok) { | |
| throw new Error(formatReviewError(response.status, payload, apiBase)); | |
| } | |
| if (!payload.review) { | |
| throw new Error("Review response was empty."); | |
| } | |
| return payload.review; | |
| } | |
| async function extractPdfText(file) { | |
| if (!window.pdfjsLib) { | |
| throw new Error("PDF reader did not load."); | |
| } | |
| window.pdfjsLib.GlobalWorkerOptions.workerSrc = WORKER_URL; | |
| const bytes = await file.arrayBuffer(); | |
| const loadingTask = window.pdfjsLib.getDocument({ data: bytes }); | |
| const pdf = await loadingTask.promise; | |
| const pages = []; | |
| for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { | |
| const page = await pdf.getPage(pageNumber); | |
| const content = await page.getTextContent({ normalizeWhitespace: true }); | |
| const rows = new Map(); | |
| for (const item of content.items) { | |
| const text = item.str.trim(); | |
| if (!text) { | |
| continue; | |
| } | |
| const y = Math.round(item.transform[5]); | |
| const x = item.transform[4]; | |
| if (!rows.has(y)) { | |
| rows.set(y, []); | |
| } | |
| rows.get(y).push({ x, text }); | |
| } | |
| const pageText = Array.from(rows.entries()) | |
| .sort((left, right) => right[0] - left[0]) | |
| .map(([, row]) => | |
| row | |
| .sort((left, right) => left.x - right.x) | |
| .map((cell) => cell.text) | |
| .join(" ") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| ) | |
| .filter(Boolean) | |
| .join("\n"); | |
| pages.push(pageText); | |
| } | |
| return pages.join("\n\n").trim(); | |
| } | |
| window.addEventListener("DOMContentLoaded", () => { | |
| const paperInput = document.getElementById("paper-input"); | |
| const reviewButton = document.getElementById("review-button"); | |
| const uploadButton = document.getElementById("upload-button"); | |
| const fileInput = document.getElementById("pdf-input"); | |
| const outputCard = document.getElementById("output-card"); | |
| getApiBase().then((apiBase) => { | |
| if (apiBase || window.location.origin === FALLBACK_API_BASE) { | |
| return; | |
| } | |
| if (isLocalPreviewHost()) { | |
| renderOutput( | |
| outputCard, | |
| "Backend not detected on this preview origin. Run npm start and open http://127.0.0.1:3000, or keep this page open and run the backend on port 3000.", | |
| true | |
| ); | |
| } | |
| }); | |
| uploadButton.addEventListener("click", () => { | |
| fileInput.click(); | |
| }); | |
| fileInput.addEventListener("change", async (event) => { | |
| const file = event.target.files && event.target.files[0]; | |
| if (!file) { | |
| return; | |
| } | |
| setBusy(true, reviewButton, uploadButton, "Review"); | |
| renderOutput(outputCard, "Reading PDF...", false); | |
| try { | |
| const extractedText = await extractPdfText(file); | |
| paperInput.value = extractedText; | |
| renderOutput(outputCard, "PDF loaded. Click Review.", false); | |
| } catch (error) { | |
| renderOutput( | |
| outputCard, | |
| error instanceof Error ? error.message : "PDF extraction failed.", | |
| true | |
| ); | |
| } finally { | |
| fileInput.value = ""; | |
| setBusy(false, reviewButton, uploadButton, "Review"); | |
| } | |
| }); | |
| reviewButton.addEventListener("click", async () => { | |
| const paperText = paperInput.value.trim(); | |
| if (!paperText) { | |
| renderOutput(outputCard, "Paste a paper or upload a PDF first.", true); | |
| return; | |
| } | |
| setBusy(true, reviewButton, uploadButton, "Reviewing..."); | |
| renderOutput(outputCard, "Reviewing...", false); | |
| try { | |
| const review = await requestReview(paperText); | |
| renderOutput(outputCard, review, false); | |
| } catch (error) { | |
| const message = | |
| error instanceof Error | |
| ? error.message | |
| : "Review failed. Please try again."; | |
| renderOutput(outputCard, message, true); | |
| } finally { | |
| setBusy(false, reviewButton, uploadButton, "Review"); | |
| } | |
| }); | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <main> | |
| <section class="intro" aria-label="About Peer Reviewer"> | |
| <h1>Peer Reviewer</h1> | |
| <p> | |
| Paste an abstract, full paper, or upload a PDF. The app extracts the text, | |
| sends it to Gemini, and gives you a skeptical quick read of the claim, the | |
| load-bearing number, the hidden assumption, the missing test, and whether | |
| the paper is safe to cite. | |
| </p> | |
| </section> | |
| <label class="sr-only" for="paper-input">Paper text</label> | |
| <textarea | |
| id="paper-input" | |
| placeholder="Paste abstract or full paper text here" | |
| spellcheck="false" | |
| ></textarea> | |
| <div class="actions"> | |
| <button id="upload-button" type="button">Upload PDF</button> | |
| <button id="review-button" type="button">Review</button> | |
| </div> | |
| <input id="pdf-input" type="file" accept="application/pdf" hidden /> | |
| <div id="output-card" class="card hidden" aria-live="polite"></div> | |
| </main> | |
| </body> | |
| </html> | |