| |
|
|
| const AP_SESSION_KEY = 'astroparse_session_v2'; |
|
|
| const AP_TWEAK_DEFAULTS = { |
| "paperPalette": ["#f6f1e6", "#2b2418", "#9a3b20"], |
| "bodySize": 19, |
| "dropCaps": true, |
| "justify": true, |
| "showScores": true, |
| "notesSide": "right" |
| }; |
|
|
| const AP_MODE_INSTRUCTIONS = { |
| history: 'Provide HISTORICAL CONTEXT: how this claim or approach emerged in the field, which earlier works set it up, and how thinking shifted over time.', |
| referee: 'Act as a constructive but rigorous REFEREE: identify the strongest assumption, an unstated caveat, or a check the authors should report. Be specific, not generic.', |
| literature: 'Offer OBSERVATIONS FROM THE RELATED LITERATURE: how the retrieved works support, contradict, or extend what this paragraph claims. Compare numbers where possible.', |
| explainer: 'Write a PLAIN-LANGUAGE EXPLAINER for a beginning graduate student: unpack jargon and explain why this paragraph matters, without dumbing down.', |
| methods: 'Provide a METHODS COMPARISON: contrast the technique in this paragraph with the approaches used in the retrieved works, noting trade-offs.', |
| }; |
|
|
| function buildPrompt(para, modeId, litList) { |
| const litBlock = litList |
| .map((l, i) => `[${i + 1}] ${l.short} — "${l.title}" (${l.journal}). ${l.abstract}`) |
| .join('\n'); |
| return ( |
| 'You are an erudite astronomical annotator writing a marginal note beside a paragraph of a research paper, ' + |
| 'in the tradition of an annotated encyclopedia. Scholarly, precise, a little wry. ' + |
| 'Write 55–85 words, a single paragraph, no markdown, no preamble. ' + |
| 'Refer to the retrieved works by bracketed number, e.g. [1], where relevant.\n\n' + |
| AP_MODE_INSTRUCTIONS[modeId] + '\n\n' + |
| 'PARAGRAPH (from section "' + para.section + '"):\n' + para.text + '\n\n' + |
| 'RETRIEVED LITERATURE (via Pathfinder):\n' + litBlock + '\n\n' + |
| 'Marginal note:' |
| ); |
| } |
|
|
| function App() { |
| const data = window.ASTROPARSE_DATA; |
| const [t, setTweak] = useTweaks(AP_TWEAK_DEFAULTS); |
| const [screen, setScreen] = React.useState('upload'); |
| const [fileName, setFileName] = React.useState('Mowla_Iyer_2024_FireflySparkle.pdf'); |
| const [noteModes, setNoteModes] = React.useState({ ...data.defaultModes }); |
| const [commentary, setCommentary] = React.useState(() => { |
| const init = {}; |
| Object.entries(data.cannedCommentary).forEach(([k, v]) => { init[k] = { ...v, source: 'canned' }; }); |
| return init; |
| }); |
| const [generating, setGenerating] = React.useState({}); |
| const [toast, setToast] = React.useState(null); |
| const [annotator, setAnnotator] = React.useState(apLoadAnnotator); |
| const [settingsOpen, setSettingsOpen] = React.useState(false); |
| const annotatorRef = React.useRef(annotator); |
| annotatorRef.current = annotator; |
| const inflight = React.useRef(new Set()); |
|
|
| |
| React.useEffect(() => { |
| try { |
| const raw = localStorage.getItem(AP_SESSION_KEY); |
| if (raw) { |
| const s = JSON.parse(raw); |
| if (s && s.commentary && s.screen === 'reader') { |
| setCommentary(s.commentary); |
| setNoteModes(s.noteModes || { ...data.defaultModes }); |
| setFileName(s.fileName || 'Mowla_Iyer_2024_FireflySparkle.pdf'); |
| setScreen('reader'); |
| } |
| } |
| } catch (e) { } |
| }, []); |
|
|
| |
| React.useEffect(() => { |
| if (screen !== 'reader') return; |
| const s = { version: 1, savedAt: new Date().toISOString(), fileName, screen, noteModes, commentary }; |
| try { localStorage.setItem(AP_SESSION_KEY, JSON.stringify(s)); } catch (e) {} |
| }, [screen, fileName, noteModes, commentary]); |
|
|
| |
| const generate = React.useCallback(async (paraId, modeId, force) => { |
| const key = paraId + ':' + modeId; |
| if (inflight.current.has(key)) return; |
| inflight.current.add(key); |
| setGenerating((g) => ({ ...g, [key]: true })); |
| const para = data.paper.paragraphs.find((p) => p.id === paraId); |
| const litIds = data.litByPara[paraId] || []; |
| const litList = litIds.map((id) => data.litPapers[id]).filter(Boolean); |
| try { |
| const text = await apComplete(annotatorRef.current, buildPrompt(para, modeId, litList)); |
| setCommentary((c) => ({ ...c, [key]: { text: String(text).trim(), cites: litIds, source: 'llm' } })); |
| } catch (e) { |
| setCommentary((c) => ({ ...c, [key]: { error: true, message: e && e.message, cites: litIds } })); |
| } finally { |
| inflight.current.delete(key); |
| setGenerating((g) => { const n = { ...g }; delete n[key]; return n; }); |
| } |
| }, []); |
|
|
| const setMode = React.useCallback((paraId, modeId) => { |
| setNoteModes((m) => (m[paraId] === modeId ? m : { ...m, [paraId]: modeId })); |
| setCommentary((c) => { |
| const key = paraId + ':' + modeId; |
| if (!c[key]) generate(paraId, modeId); |
| return c; |
| }); |
| }, [generate]); |
|
|
| const regenerate = React.useCallback((paraId, modeId) => generate(paraId, modeId, true), [generate]); |
|
|
| |
| const exportSession = () => { |
| const s = { version: 1, savedAt: new Date().toISOString(), fileName, screen: 'reader', noteModes, commentary, paper: data.paper }; |
| const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = 'astroparse-session.json'; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| flash('session written to astroparse-session.json'); |
| }; |
|
|
| const importSession = (text) => { |
| try { |
| const s = JSON.parse(text); |
| if (!s || !s.commentary) throw new Error('bad session'); |
| setCommentary(s.commentary); |
| setNoteModes(s.noteModes || { ...data.defaultModes }); |
| setFileName(s.fileName || 'restored-session.pdf'); |
| setScreen('reader'); |
| flash('session restored'); |
| } catch (e) { |
| flash('could not read that session file'); |
| } |
| }; |
|
|
| const flash = (msg) => { |
| setToast(msg); |
| setTimeout(() => setToast(null), 2600); |
| }; |
|
|
| const reset = () => { |
| setScreen('upload'); |
| setNoteModes({ ...data.defaultModes }); |
| setCommentary(() => { |
| const init = {}; |
| Object.entries(data.cannedCommentary).forEach(([k, v]) => { init[k] = { ...v, source: 'canned' }; }); |
| return init; |
| }); |
| try { localStorage.removeItem(AP_SESSION_KEY); } catch (e) {} |
| }; |
|
|
| const importRef = React.useRef(null); |
|
|
| const toolbar = ( |
| <div className="ap-toolbar"> |
| <span className="ap-toolbar-mark">astroparse</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()} |
| </button> |
| <button className="ap-textbtn" onClick={exportSession}>save session ↓</button> |
| <button className="ap-textbtn" onClick={() => importRef.current && importRef.current.click()}>load</button> |
| <button className="ap-textbtn" onClick={reset}>new manuscript</button> |
| <input |
| ref={importRef} type="file" accept="application/json" style={{ display: 'none' }} |
| onChange={(e) => { |
| const f = e.target.files[0]; if (!f) return; |
| const r = new FileReader(); |
| r.onload = () => importSession(r.result); |
| r.readAsText(f); |
| e.target.value = ''; |
| }} |
| /> |
| </div> |
| ); |
|
|
| const pal = t.paperPalette || AP_TWEAK_DEFAULTS.paperPalette; |
| const rootStyle = { |
| '--paper': pal[0], '--ink': pal[1], '--rubric': pal[2], |
| '--body-size': t.bodySize + 'px', |
| }; |
|
|
| return ( |
| <div className="ap-root" style={rootStyle}> |
| {screen === 'upload' && ( |
| <UploadScreen |
| onFile={(name) => { setFileName(name); setScreen('pipeline'); }} |
| onSample={() => { setFileName('Mowla_Iyer_2024_FireflySparkle.pdf'); setScreen('pipeline'); }} |
| onImportSession={importSession} |
| /> |
| )} |
| {screen === 'pipeline' && <PipelineScreen fileName={fileName} onDone={() => setScreen('reader')} />} |
| {screen === 'reader' && ( |
| <Reader |
| paper={data.paper} |
| commentary={commentary} |
| noteModes={noteModes} |
| generating={generating} |
| onSetMode={setMode} |
| onRegenerate={regenerate} |
| tweaks={t} |
| toolbar={toolbar} |
| /> |
| )} |
| {toast && <div className="ap-toast">{toast}</div>} |
| {settingsOpen && ( |
| <AnnotatorSettings |
| cfg={annotator} |
| onChange={(c) => { setAnnotator(c); apSaveAnnotator(c); }} |
| onClose={() => setSettingsOpen(false)} |
| /> |
| )} |
| |
| <TweaksPanel> |
| <TweakSection label="Paper" /> |
| <TweakColor |
| label="Palette" value={t.paperPalette} |
| options={[ |
| ["#f6f1e6", "#2b2418", "#9a3b20"], |
| ["#fbf8f1", "#231f17", "#8a5a18"], |
| ["#f3f2ee", "#22241f", "#3f5a96"], |
| ]} |
| onChange={(v) => setTweak('paperPalette', v)} |
| /> |
| <TweakSlider label="Body size" value={t.bodySize} min={16} max={23} unit="px" onChange={(v) => setTweak('bodySize', v)} /> |
| <TweakToggle label="Drop caps" value={t.dropCaps} onChange={(v) => setTweak('dropCaps', v)} /> |
| <TweakToggle label="Justified text" value={t.justify} onChange={(v) => setTweak('justify', v)} /> |
| <TweakSection label="Marginalia" /> |
| <TweakRadio label="Notes side" value={t.notesSide} options={['right', 'left']} onChange={(v) => setTweak('notesSide', v)} /> |
| <TweakToggle label="Retrieval scores" value={t.showScores} onChange={(v) => setTweak('showScores', v)} /> |
| </TweaksPanel> |
| </div> |
| ); |
| } |
|
|
| ReactDOM.createRoot(document.getElementById('root')).render(<App />); |
|
|