beacon / prototype /app.jsx
kiyer's picture
chore: move prototype to prototype/, scaffold backend with uv
ccf55cb
Raw
History Blame Contribute Delete
10 kB
// astroparse — root app: screens, session persistence, LLM generation, tweaks
const AP_SESSION_KEY = 'astroparse_session_v2';
const AP_TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"paperPalette": ["#f6f1e6", "#2b2418", "#9a3b20"],
"bodySize": 19,
"dropCaps": true,
"justify": true,
"showScores": true,
"notesSide": "right"
}/*EDITMODE-END*/;
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'); // upload | pipeline | reader
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());
/* ---------- restore session from localStorage ---------- */
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) { /* ignore corrupt session */ }
}, []);
/* ---------- autosave ---------- */
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]);
/* ---------- LLM generation ---------- */
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]);
/* ---------- session export / import ---------- */
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 &darr;</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 />);