| import React from 'react'; |
| import Upload from './screens/Upload'; |
| import Pipeline from './screens/Pipeline'; |
| import Reader from './screens/Reader'; |
| import AnnotatorPanel, { apLoadAnnotator, apSaveAnnotator, AP_PROVIDERS } from './components/AnnotatorPanel'; |
| import DisplayMenu, { PALETTES } from './components/DisplayMenu'; |
| import Toast from './components/Toast'; |
| import { startParse, jobEvents, annotateStream } from './lib/api'; |
| import { hashPdf, exportSession, importSession } from './lib/session'; |
| import FigurePopover from './components/FigurePopover'; |
| import NotebookDrawer from './components/NotebookDrawer'; |
|
|
| |
| const DEFAULT_MODE = 'history'; |
|
|
| |
| |
| |
| const DISPLAY_DEFAULTS = { |
| paperPalette: ['#f6f1e6', '#2b2418', '#9a3b20'], |
| bodySize: 19, |
| dropCaps: true, |
| justify: true, |
| showScores: true, |
| notesSide: 'right', |
| litCount: 3, |
| }; |
|
|
| const DISPLAY_KEY = 'astroparse_display_v1'; |
| const SESSION_KEY = 'astroparse_session_v2'; |
|
|
| function loadDisplay() { |
| try { |
| const raw = localStorage.getItem(DISPLAY_KEY); |
| if (raw) { |
| const v = JSON.parse(raw); |
| if (v && Array.isArray(v.paperPalette) && v.paperPalette.length === 3) return { ...DISPLAY_DEFAULTS, ...v }; |
| } |
| } catch (e) { } |
| return { ...DISPLAY_DEFAULTS }; |
| } |
|
|
| export default function App() { |
| const [screen, setScreen] = React.useState('upload'); |
| const [fileName, setFileName] = React.useState(''); |
| const [stageEvents, setStageEvents] = React.useState([]); |
| const [result, setResult] = React.useState(null); |
| const [pdfHash, setPdfHash] = React.useState(''); |
|
|
| |
| const [noteModes, setNoteModes] = React.useState({}); |
| |
| const [commentary, setCommentary] = React.useState({}); |
| const commentaryRef = React.useRef(commentary); |
| commentaryRef.current = commentary; |
| |
| const [generating, setGenerating] = React.useState({}); |
|
|
| |
| const [figPop, setFigPop] = React.useState(null); |
|
|
| |
| const [userNotes, setUserNotes] = React.useState({}); |
| const [paperNote, setPaperNote] = React.useState(''); |
| const [notebookOpen, setNotebookOpen] = React.useState(false); |
|
|
| |
| const [toast, setToast] = React.useState(null); |
| const toastTimerRef = React.useRef(null); |
|
|
| |
| const [annotator, setAnnotator] = React.useState(apLoadAnnotator); |
| const [settingsOpen, setSettingsOpen] = React.useState(false); |
|
|
| |
| const [tweaks, setTweaks] = React.useState(loadDisplay); |
| const [displayOpen, setDisplayOpen] = React.useState(false); |
|
|
| |
| const inflight = React.useRef(new Set()); |
|
|
| |
| const importRef = React.useRef(null); |
|
|
| |
| const [lastFileName, setLastFileName] = React.useState(null); |
| |
| const storedSessionRef = React.useRef(null); |
|
|
| |
| const showToast = React.useCallback((msg) => { |
| setToast(msg); |
| if (toastTimerRef.current) clearTimeout(toastTimerRef.current); |
| toastTimerRef.current = setTimeout(() => setToast(null), 2600); |
| }, []); |
|
|
| const handleSaveNote = React.useCallback((paraId, text) => { |
| setUserNotes((prev) => { |
| if (!text.trim()) { |
| const next = { ...prev }; delete next[paraId]; return next; |
| } |
| return { ...prev, [paraId]: { text, updatedAt: new Date().toISOString() } }; |
| }); |
| }, []); |
|
|
| const handleSavePaperNote = React.useCallback((text) => { |
| setPaperNote(text); |
| }, []); |
|
|
| |
| |
| |
| const restoreSession = React.useCallback((text) => { |
| try { |
| const s = importSession(text); |
| fromParseRef.current = false; |
| setFileName(s.fileName || ''); |
| setResult({ |
| paper: s.paper, |
| litPapers: s.litPapers, |
| litByPara: s.litByPara, |
| figures: s.figures || [], |
| litByFig: s.litByFig || {}, |
| equations: s.equations || [], |
| references: s.references || [], |
| citeIndex: s.citeIndex || {}, |
| }); |
| setPdfHash(s.pdfHash || ''); |
| if (s.noteModes) { |
| setNoteModes(s.noteModes); |
| } else { |
| const modes = {}; |
| (s.paper?.paragraphs || []).forEach((p) => { modes[p.id] = DEFAULT_MODE; }); |
| setNoteModes(modes); |
| } |
| setCommentary(s.commentary || {}); |
| setGenerating({}); |
| setUserNotes(s.userNotes || {}); |
| setPaperNote(s.paperNote || ''); |
| setScreen('reader'); |
| showToast('session restored'); |
| } catch { |
| showToast('could not read that session file'); |
| } |
| }, [showToast]); |
|
|
| |
| React.useEffect(() => { |
| try { |
| const raw = localStorage.getItem(SESSION_KEY); |
| if (raw) { |
| const parsed = JSON.parse(raw); |
| if (parsed && parsed.fileName) { |
| storedSessionRef.current = raw; |
| setLastFileName(parsed.fileName); |
| } |
| } |
| } catch (e) { } |
| }, []); |
|
|
| |
| |
| React.useEffect(() => { |
| if (screen !== 'reader' || !result) return; |
| try { |
| const json = exportSession({ |
| fileName, |
| pdfHash, |
| paper: result.paper, |
| litPapers: result.litPapers, |
| litByPara: result.litByPara, |
| figures: result.figures || [], |
| litByFig: result.litByFig || {}, |
| equations: result.equations || [], |
| references: result.references || [], |
| citeIndex: result.citeIndex || {}, |
| userNotes, |
| paperNote, |
| noteModes, |
| commentary, |
| }); |
| localStorage.setItem(SESSION_KEY, json); |
| } catch (e) { } |
| }, [commentary, noteModes, userNotes, paperNote]); |
|
|
| |
| React.useEffect(() => { |
| try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(tweaks)); } catch (e) { } |
| }, [tweaks]); |
|
|
| |
| const pal = tweaks.paperPalette || DISPLAY_DEFAULTS.paperPalette; |
| const rootStyle = { |
| '--paper': pal[0], |
| '--ink': pal[1], |
| '--rubric': pal[2], |
| '--body-size': tweaks.bodySize + 'px', |
| }; |
|
|
| |
| |
| const generate = React.useCallback(async (targetId, modeId) => { |
| const k = `${targetId}:${modeId}`; |
| if (inflight.current.has(k)) return; |
| if (!result) return; |
| inflight.current.add(k); |
| setGenerating((g) => ({ ...g, [k]: true })); |
| |
| const para = result.paper.paragraphs.find((p) => p.id === targetId); |
| let paragraphText, section, litIds; |
| if (para) { |
| paragraphText = para.text; |
| section = para.section; |
| litIds = result.litByPara[targetId] || []; |
| } else { |
| const fig = (result.figures || []).find((f) => f.id === targetId); |
| if (!fig) { |
| inflight.current.delete(k); |
| setGenerating((g) => { const n = { ...g }; delete n[k]; return n; }); |
| return; |
| } |
| paragraphText = fig.caption; |
| section = fig.label; |
| litIds = (result.litByFig || {})[targetId] || []; |
| } |
| const slicedLitIds = litIds.slice(0, tweaks.litCount); |
| const lit = slicedLitIds.map((id) => result.litPapers[id]).filter(Boolean); |
| const paraId = targetId; |
|
|
| let paperContext = null; |
| if (modeId === 'referee' && para) { |
| const paras = result.paper.paragraphs; |
| const idx = paras.findIndex((p) => p.id === targetId); |
| paperContext = { |
| title: result.paper.title, |
| sectionOutline: [...new Set(paras.map((p) => p.section))], |
| opening: paras[0]?.text?.slice(0, 400) || '', |
| prevParagraph: idx > 0 ? paras[idx - 1].text.slice(0, 300) : '', |
| nextParagraph: idx < paras.length - 1 ? paras[idx + 1].text.slice(0, 300) : '', |
| }; |
| } |
|
|
| try { |
| setCommentary((c) => ({ ...c, [k]: { text: '', cites: slicedLitIds, source: 'llm', streaming: true } })); |
| for await (const ev of annotateStream({ |
| paragraphId: paraId, |
| paragraph: paragraphText, |
| section: section, |
| mode: modeId, |
| lit: lit.map((l) => ({ |
| id: l.id, |
| short: l.short, |
| title: l.title, |
| journal: l.journal || 'arXiv', |
| abstract: l.abstract, |
| })), |
| provider: annotator.provider, |
| model: annotator.model, |
| key: annotator.key, |
| paperContext, |
| })) { |
| if (ev.event === 'token') { |
| setCommentary((c) => ({ ...c, [k]: { ...c[k], text: (c[k]?.text || '') + ev.data.text } })); |
| } else if (ev.event === 'done') { |
| setCommentary((c) => ({ ...c, [k]: { text: ev.data.text, cites: slicedLitIds, source: 'llm' } })); |
| } else if (ev.event === 'error') { |
| setCommentary((c) => ({ ...c, [k]: { text: '', cites: [], source: 'llm', error: true, message: ev.data.message } })); |
| } |
| } |
| } catch (e) { |
| const msg = String(e.message || e).slice(0, 200); |
| setCommentary((c) => ({ ...c, [k]: { text: '', cites: [], source: 'llm', error: true, message: msg } })); |
| if (e instanceof TypeError || (e.message && e.message.startsWith('Failed to fetch'))) { |
| showToast('could not reach the annotator backend'); |
| } |
| } finally { |
| inflight.current.delete(k); |
| setGenerating((g) => { const n = { ...g }; delete n[k]; return n; }); |
| } |
| }, [result, annotator, tweaks, showToast]); |
|
|
| |
| const fromParseRef = React.useRef(false); |
| const prevResultRef = React.useRef(null); |
|
|
| React.useEffect(() => { |
| if (!result || result === prevResultRef.current) return; |
| prevResultRef.current = result; |
| if (!fromParseRef.current) return; |
| result.paper.paragraphs.forEach((p) => { |
| generate(p.id, DEFAULT_MODE); |
| }); |
| }, [result, generate]); |
|
|
| async function onPdf(file) { |
| setFileName(file.name); |
| setScreen('pipeline'); |
| setStageEvents([]); |
| setResult(null); |
| setNoteModes({}); |
| setCommentary({}); |
| setGenerating({}); |
| fromParseRef.current = true; |
| try { |
| setPdfHash(await hashPdf(await file.arrayBuffer())); |
| const jobId = await startParse(file); |
| for await (const ev of jobEvents(jobId)) { |
| if (ev.event === 'stage') { |
| setStageEvents((s) => [...s, ev.data]); |
| } else if (ev.event === 'result') { |
| const data = ev.data; |
| |
| if (data.pdfHash) setPdfHash(data.pdfHash); |
| setResult(data); |
| const modes = {}; |
| (data.paper?.paragraphs || []).forEach((p) => { modes[p.id] = DEFAULT_MODE; }); |
| setNoteModes(modes); |
| setCommentary({}); |
| setGenerating({}); |
| setScreen('reader'); |
| } else if (ev.event === 'error') { |
| setStageEvents((s) => { |
| const failedIndex = s.length ? Math.max(...s.map((x) => x.index)) : 0; |
| return [...s, { index: failedIndex, name: '', status: 'failed', log: ev.data.message }]; |
| }); |
| } |
| } |
| } catch (e) { |
| setStageEvents((s) => [ |
| ...s, |
| { index: 0, name: '', status: 'failed', log: String(e.message || e) }, |
| ]); |
| } |
| } |
|
|
| function onSetMode(paraId, modeId) { |
| setNoteModes((m) => (m[paraId] === modeId ? m : { ...m, [paraId]: modeId })); |
| if (!commentaryRef.current[`${paraId}:${modeId}`]) generate(paraId, modeId); |
| } |
|
|
| function onRegenerate(paraId, modeId) { |
| generate(paraId, modeId); |
| } |
|
|
| function onSetAllModes(modeId) { |
| if (!result?.paper?.paragraphs) return; |
| const next = {}; |
| result.paper.paragraphs.forEach((p) => { next[p.id] = modeId; }); |
| setNoteModes(next); |
| result.paper.paragraphs.forEach((p) => { |
| if (!commentaryRef.current[`${p.id}:${modeId}`]) generate(p.id, modeId); |
| }); |
| } |
|
|
| function onReset() { |
| setScreen('upload'); |
| setResult(null); |
| setNoteModes({}); |
| setCommentary({}); |
| setGenerating({}); |
| setFileName(''); |
| fromParseRef.current = false; |
| } |
|
|
| |
| function onSaveSession() { |
| if (!result) return; |
| const json = exportSession({ |
| fileName, |
| pdfHash, |
| paper: result.paper, |
| litPapers: result.litPapers, |
| litByPara: result.litByPara, |
| figures: result.figures || [], |
| litByFig: result.litByFig || {}, |
| equations: result.equations || [], |
| references: result.references || [], |
| citeIndex: result.citeIndex || {}, |
| userNotes, |
| paperNote, |
| noteModes, |
| commentary, |
| }); |
| const blob = new Blob([json], { type: 'application/json' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = fileName.replace(/\.pdf$/i, '') + '.beacon.json'; |
| a.click(); |
| URL.revokeObjectURL(url); |
| showToast('session written to ‹' + a.download + '›'); |
| } |
|
|
| |
| function onOpenLast() { |
| if (storedSessionRef.current) { |
| restoreSession(storedSessionRef.current); |
| } |
| } |
|
|
| |
| const toolbar = ( |
| <div className="ap-toolbar"> |
| <span className="ap-toolbar-mark">beacon</span> |
| <span className="ap-toolbar-file">{fileName}</span> |
| <span className="ap-toolbar-spacer"></span> |
| <button className="ap-textbtn" onClick={() => setSettingsOpen(true)}> |
| annotator: {AP_PROVIDERS[annotator.provider]?.label.toLowerCase() || annotator.provider} |
| </button> |
| <button className="ap-textbtn" onClick={() => setDisplayOpen(true)}>display</button> |
| <button className="ap-toolbar-btn" onClick={() => setNotebookOpen(true)} title="open notebook">📓</button> |
| <button className="ap-textbtn" onClick={onSaveSession}> |
| save session ↓ |
| </button> |
| <button |
| className="ap-textbtn" |
| onClick={() => importRef.current && importRef.current.click()} |
| > |
| load |
| </button> |
| <button className="ap-textbtn" onClick={onReset}>new manuscript</button> |
| <input |
| ref={importRef} |
| type="file" |
| accept=".json,application/json" |
| style={{ display: 'none' }} |
| onChange={(e) => { |
| const f = e.target.files[0]; if (!f) return; |
| const r = new FileReader(); |
| r.onload = () => restoreSession(r.result); |
| r.readAsText(f); |
| e.target.value = ''; |
| }} |
| /> |
| </div> |
| ); |
|
|
| return ( |
| <div className="ap-root" style={rootStyle}> |
| {screen === 'upload' && ( |
| <Upload |
| onPdf={onPdf} |
| lastFileName={lastFileName} |
| onOpenLast={onOpenLast} |
| onSession={restoreSession} |
| /> |
| )} |
| {screen === 'pipeline' && ( |
| <Pipeline |
| fileName={fileName} |
| events={stageEvents} |
| onSkip={result ? () => setScreen('reader') : null} |
| onReset={() => setScreen('upload')} |
| /> |
| )} |
| {screen === 'reader' && result && ( |
| <Reader |
| paper={result.paper} |
| litPapers={result.litPapers || {}} |
| litByPara={result.litByPara || {}} |
| figures={result.figures || []} |
| pdfHash={pdfHash} |
| equations={result.equations || []} |
| commentary={commentary} |
| noteModes={noteModes} |
| generating={generating} |
| onSetMode={onSetMode} |
| onRegenerate={onRegenerate} |
| onSetAllModes={onSetAllModes} |
| onOpenFigure={(n, e) => { |
| const figures = result.figures || []; |
| const fig = figures.find((f) => { |
| const labelNum = f.label.replace(/^(Figure|Fig\.?)\s*/i, '').trim(); |
| return labelNum === n; |
| }); |
| if (!fig) return; |
| const r = e.currentTarget.getBoundingClientRect(); |
| setFigPop({ fig, n, pos: { x: r.left, y: r.bottom } }); |
| }} |
| tweaks={tweaks} |
| toolbar={toolbar} |
| citeIndex={result?.citeIndex || {}} |
| references={result?.references || []} |
| userNotes={userNotes} |
| onSaveNote={handleSaveNote} |
| /> |
| )} |
| <Toast msg={toast} /> |
| {settingsOpen && ( |
| <AnnotatorPanel |
| cfg={annotator} |
| onChange={(c) => { setAnnotator(c); apSaveAnnotator(c); }} |
| onClose={() => setSettingsOpen(false)} |
| /> |
| )} |
| {displayOpen && ( |
| <DisplayMenu |
| tweaks={tweaks} |
| onChange={(t) => setTweaks(t)} |
| onClose={() => setDisplayOpen(false)} |
| /> |
| )} |
| {figPop && ( |
| <FigurePopover |
| fig={figPop.fig} |
| n={figPop.n} |
| pdfHash={pdfHash} |
| pos={figPop.pos} |
| commentary={commentary} |
| generating={generating} |
| onGenerate={generate} |
| onClose={() => setFigPop(null)} |
| /> |
| )} |
| <NotebookDrawer |
| open={notebookOpen} |
| onClose={() => setNotebookOpen(false)} |
| paper={result?.paper} |
| userNotes={userNotes} |
| paperNote={paperNote} |
| onSavePaperNote={handleSavePaperNote} |
| onScrollToPara={(paraId) => { |
| document.querySelector(`[data-comment-anchor="para-${paraId}"]`) |
| ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| setNotebookOpen(false); |
| }} |
| /> |
| </div> |
| ); |
| } |
|
|