Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions static/app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as pdfjsLib from "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.mjs";
import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.es.mjs";
import { marked } from "https://cdn.jsdelivr.net/npm/marked@15.0.12/lib/marked.esm.js";
import { linkifyChatPageReferences } from "./chat_references.js";

pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.mjs";
Expand Down Expand Up @@ -2414,6 +2415,56 @@ function jumpToPage(pageNumber) {
}
}

function pageReferenceSelection(pageNumber) {
const pageSize = pageSizeFor(pageNumber);
if (pageSize?.width && pageSize?.height) {
const marginX = Math.max(8, pageSize.width * 0.025);
const marginY = Math.max(8, pageSize.height * 0.025);
return {
pageNumber,
rects: [[
marginX,
marginY,
Math.max(marginX + 4, pageSize.width - marginX),
Math.max(marginY + 4, pageSize.height - marginY),
]],
};
}

const page = els.pdfViewer?.querySelector(`[data-page-number="${pageNumber}"]`);
if (!page) {
return { pageNumber };
}

const inset = 10;
return {
pageNumber,
previewRects: [[
inset,
inset,
Math.max(inset + 4, page.clientWidth - inset),
Math.max(inset + 4, page.clientHeight - inset),
]],
};
}

function jumpToChatPageReference(pageNumber) {
if (!pageNumber) {
return;
}
const page = els.pdfViewer?.querySelector(`[data-page-number="${pageNumber}"]`);
if (!page) {
return;
}

jumpToPage(pageNumber);
showSelectionPreview(pageReferenceSelection(pageNumber), {
className: "chat-page-reference-rect",
flash: true,
durationMs: 2200,
});
}

function highlightsByPage(highlights) {
const map = new Map();
for (const [index, highlight] of (highlights || []).entries()) {
Expand Down Expand Up @@ -2905,7 +2956,7 @@ function renderChat() {

const body = document.createElement("div");
body.className = "chat-markdown";
renderChatMarkdown(body, message.content);
renderChatMarkdown(body, message.content, { linkPageReferences: role === "assistant" });

article.append(label, body);
els.chatMessages.appendChild(article);
Expand All @@ -2914,7 +2965,7 @@ function renderChat() {
els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
}

function renderChatMarkdown(target, value) {
function renderChatMarkdown(target, value, options = {}) {
const unsafeHtml = marked.parse(String(value || ""));
target.innerHTML = DOMPurify.sanitize(unsafeHtml, {
ALLOWED_TAGS: [
Expand Down Expand Up @@ -2953,6 +3004,33 @@ function renderChatMarkdown(target, value) {
link.target = "_blank";
link.rel = "noreferrer";
});

if (!options.linkPageReferences) {
return;
}
linkifyChatPageReferences(target, { pageCount: currentPageCount() });
target.querySelectorAll("[data-chat-page-reference]").forEach((button) => {
button.addEventListener("click", () => {
jumpToChatPageReference(Number(button.dataset.chatPageReference));
});
});
}

function currentPageCount() {
const pageNumbers = [];
for (const page of state.selectedPaper?.page_sizes || []) {
const pageNumber = Number(page.page_number);
if (pageNumber) {
pageNumbers.push(pageNumber);
}
}
els.pdfViewer?.querySelectorAll(".pdf-page[data-page-number]").forEach((page) => {
const pageNumber = Number(page.dataset.pageNumber);
if (pageNumber) {
pageNumbers.push(pageNumber);
}
});
return pageNumbers.length ? Math.max(...pageNumbers) : 0;
}

function pageSizeFor(pageNumber) {
Expand Down Expand Up @@ -3127,6 +3205,7 @@ function showSelectionPreview(selection, options = {}) {
return;
}

const previewNodes = [];
for (const rect of selectionPreviewRects(selection, page)) {
const [x0, y0, x1, y1] = rect;
const node = document.createElement("div");
Expand All @@ -3140,6 +3219,13 @@ function showSelectionPreview(selection, options = {}) {
node.style.width = `${Math.max(4, x1 - x0)}px`;
node.style.height = `${Math.max(4, y1 - y0)}px`;
overlay.appendChild(node);
previewNodes.push(node);
}

if (options.durationMs) {
window.setTimeout(() => {
previewNodes.forEach((node) => node.remove());
}, options.durationMs);
}
}

Expand Down
98 changes: 98 additions & 0 deletions static/chat_references.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const PAGE_REFERENCE_PATTERN = /\b(?:p(?:g|age)?|pages?)\.?\s*(\d{1,4})\b/gi;
const SKIP_REFERENCE_TAGS = new Set(["A", "BUTTON", "CODE", "PRE", "SCRIPT", "STYLE", "TEXTAREA"]);

function validPageNumber(rawPageNumber, pageCount = 0) {
const pageNumber = Number.parseInt(rawPageNumber, 10);
const maxPageNumber = Number(pageCount) || 0;
if (!Number.isInteger(pageNumber) || pageNumber < 1) {
return null;
}
if (maxPageNumber && pageNumber > maxPageNumber) {
return null;
}
return pageNumber;
}

export function chatPageReferenceSegments(value, pageCount = 0) {
const text = String(value || "");
const segments = [];
let cursor = 0;

for (const match of text.matchAll(PAGE_REFERENCE_PATTERN)) {
const pageNumber = validPageNumber(match[1], pageCount);
if (!pageNumber) {
continue;
}

if (match.index > cursor) {
segments.push({ text: text.slice(cursor, match.index) });
}
segments.push({ text: match[0], pageNumber });
cursor = match.index + match[0].length;
}

if (!segments.length) {
return [{ text }];
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor) });
}
return segments;
}

function shouldSkipTextNode(node) {
const parent = node.parentElement;
return !parent || Boolean(parent.closest(Array.from(SKIP_REFERENCE_TAGS).join(",")));
}

function pageReferenceButton(documentRef, segment) {
const button = documentRef.createElement("button");
button.className = "chat-page-reference";
button.type = "button";
button.dataset.chatPageReference = String(segment.pageNumber);
button.textContent = segment.text;
button.title = `Jump to page ${segment.pageNumber}`;
button.setAttribute("aria-label", `Jump to page ${segment.pageNumber}`);
return button;
}

export function linkifyChatPageReferences(root, options = {}) {
if (!root) {
return 0;
}

const documentRef = root.ownerDocument || globalThis.document;
if (!documentRef) {
return 0;
}

const textNodes = [];
const showText = documentRef.defaultView?.NodeFilter?.SHOW_TEXT || globalThis.NodeFilter?.SHOW_TEXT || 4;
const walker = documentRef.createTreeWalker(root, showText);
while (walker.nextNode()) {
if (!shouldSkipTextNode(walker.currentNode)) {
textNodes.push(walker.currentNode);
}
}

let referenceCount = 0;
for (const node of textNodes) {
const segments = chatPageReferenceSegments(node.nodeValue || "", options.pageCount || 0);
if (!segments.some((segment) => segment.pageNumber)) {
continue;
}

const fragment = documentRef.createDocumentFragment();
for (const segment of segments) {
if (segment.pageNumber) {
fragment.appendChild(pageReferenceButton(documentRef, segment));
referenceCount += 1;
} else if (segment.text) {
fragment.appendChild(documentRef.createTextNode(segment.text));
}
}
node.replaceWith(fragment);
}

return referenceCount;
}
26 changes: 26 additions & 0 deletions static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,11 @@ h2 {
outline-color: var(--accent-strong);
}

.chat-page-reference-rect {
background: rgba(255, 211, 109, 0.18);
outline: 2px solid var(--amber);
}

@keyframes takeaway-flash {
0% {
background: rgba(255, 211, 109, 0.42);
Expand Down Expand Up @@ -1699,6 +1704,27 @@ h2 {
word-break: break-word;
}

.chat-page-reference {
display: inline;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--accent-text);
padding: 0 2px;
font: inherit;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
word-break: normal;
}

.chat-page-reference:hover,
.chat-page-reference:focus-visible {
background: var(--accent-soft);
outline: 1px solid var(--citation-outline);
}

.chat-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 54px 104px;
Expand Down
4 changes: 2 additions & 2 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/styles.css?v=76" />
<link rel="stylesheet" href="/static/styles.css?v=77" />
</head>
<body class="reader-body">
<header class="topbar">
Expand Down Expand Up @@ -217,6 +217,6 @@ <h2>Chat</h2>
<div id="citation-popover" class="citation-popover hidden"></div>
<div id="toast" class="toast hidden"></div>
<script type="module" src="/static/theme.js?v=14"></script>
<script type="module" src="/static/app.js?v=86"></script>
<script type="module" src="/static/app.js?v=87"></script>
</body>
</html>
58 changes: 58 additions & 0 deletions tests/test_chat_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import json
import shutil
import subprocess
import textwrap
from pathlib import Path

import pytest


ROOT = Path(__file__).resolve().parents[1]


def run_chat_reference_script(script: str):
if not shutil.which("node"):
pytest.skip("node is not available")

result = subprocess.run(
["node", "--input-type=module", "-e", textwrap.dedent(script)],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)


def test_chat_page_reference_segments_detects_common_page_forms():
payload = run_chat_reference_script(
"""
import { chatPageReferenceSegments } from "./static/chat_references.js";

const segments = chatPageReferenceSegments(
"See pg.1, pg. 2, page 3, and p. 4 for details.",
4,
);
console.log(JSON.stringify(segments));
""",
)

references = [segment for segment in payload if segment.get("pageNumber")]
assert [segment["text"] for segment in references] == ["pg.1", "pg. 2", "page 3", "p. 4"]
assert [segment["pageNumber"] for segment in references] == [1, 2, 3, 4]
assert "".join(segment["text"] for segment in payload) == "See pg.1, pg. 2, page 3, and p. 4 for details."


def test_chat_page_reference_segments_ignores_pages_outside_paper():
payload = run_chat_reference_script(
"""
import { chatPageReferenceSegments } from "./static/chat_references.js";

const segments = chatPageReferenceSegments("page 0, page 2, and page 8", 5);
console.log(JSON.stringify(segments));
""",
)

references = [segment for segment in payload if segment.get("pageNumber")]
assert references == [{"text": "page 2", "pageNumber": 2}]
assert "".join(segment["text"] for segment in payload) == "page 0, page 2, and page 8"