Peer_Reviewer / index.html
KChad's picture
Prepare Peer Reviewer for Hugging Face Spaces
6b5c39c
Raw
History Blame Contribute Delete
10.4 kB
<!DOCTYPE html>
<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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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>