| |
| |
| |
| |
|
|
| const { useState, useEffect, useMemo, useRef } = React; |
|
|
| const PALETTE = { |
| light: { |
| bg: '#f4ede2', paper: '#fbf7ef', |
| ink: '#1a1410', inkSoft: '#3a2e25', |
| muted: '#6b5c4a', faint: '#a8967f', |
| rule: '#d6c8b4', ruleSoft: '#e8ddc9', |
| accent: '#b8412e', accentSoft: '#f3dcd0', |
| mark: '#d4af37', |
| }, |
| dark: { |
| bg: '#1a1410', paper: '#221a14', |
| ink: '#f4ede2', inkSoft: '#d6c8b4', |
| muted: '#a8967f', faint: '#6b5c4a', |
| rule: '#3a2e25', ruleSoft: '#2a2017', |
| accent: '#e8755e', accentSoft: '#2a1814', |
| mark: '#e0c060', |
| }, |
| }; |
|
|
| const CAT = { |
| diagnosis: { label: 'Diagnosis', hint: 'Clinical condition' }, |
| visit: { label: 'Visit', hint: 'Patient encounter' }, |
| lab: { label: 'Lab', hint: 'Laboratory result' }, |
| report: { label: 'Report', hint: 'Clinical report or summary' }, |
| scan: { label: 'Scan', hint: 'Medical imaging' }, |
| procedure: { label: 'Procedure', hint: 'Operation or intervention' }, |
| med: { label: 'Medication', hint: 'Prescribed drug' }, |
| note: { label: 'Note', hint: 'Free-text clinical note' }, |
| photo: { label: 'Photo', hint: 'Patient-supplied image' }, |
| other: { label: 'Other', hint: 'Uncategorized event' }, |
| }; |
|
|
| |
| |
| const ICONS = { |
| |
| diagnosis: ( |
| <g> |
| <path d="M7.86 2h8.28L22 7.86v8.28L16.14 22H7.86L2 16.14V7.86z" /> |
| <path d="M12 8v4" /> |
| <path d="M12 16h.01" /> |
| </g> |
| ), |
| |
| visit: ( |
| <g> |
| <path d="M11 2v2" /> |
| <path d="M5 2v2" /> |
| <path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1" /> |
| <path d="M8 15a6 6 0 0 0 12 0v-3" /> |
| <circle cx="20" cy="10" r="2" /> |
| </g> |
| ), |
| |
| lab: ( |
| <g> |
| <path d="M10 2v6.5L3.5 19a1 1 0 0 0 .9 1.5h15.2a1 1 0 0 0 .9-1.5L14 8.5V2" /> |
| <path d="M9 2h6" /> |
| <path d="M6.4 14.5h11.2" /> |
| </g> |
| ), |
| |
| report: ( |
| <g> |
| <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> |
| <path d="M14 2v6h6" /> |
| <path d="M9 13h6" /> |
| <path d="M9 17h4" /> |
| </g> |
| ), |
| |
| scan: ( |
| <g> |
| <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> |
| <circle cx="9" cy="9" r="2" /> |
| <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /> |
| </g> |
| ), |
| |
| procedure: ( |
| <g> |
| <circle cx="6" cy="6" r="3" /> |
| <path d="M8.12 8.12 12 12" /> |
| <path d="M20 4 8.12 15.88" /> |
| <circle cx="6" cy="18" r="3" /> |
| <path d="M14.8 14.8 20 20" /> |
| </g> |
| ), |
| |
| med: ( |
| <g> |
| <path d="m10.5 20.5 10-10a4.95 4.95 0 1 0-7-7l-10 10a4.95 4.95 0 1 0 7 7Z" /> |
| <path d="m8.5 8.5 7 7" /> |
| </g> |
| ), |
| |
| note: ( |
| <g> |
| <path d="M12 20h9" /> |
| <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" /> |
| </g> |
| ), |
| |
| photo: ( |
| <g> |
| <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" /> |
| <circle cx="12" cy="13" r="3" /> |
| </g> |
| ), |
| |
| other: ( |
| <circle cx="12" cy="12" r="3" /> |
| ), |
| }; |
|
|
| function EventIcon({ category, size = 12 }) { |
| const paths = ICONS[category] || ICONS.other; |
| return ( |
| <svg width={size} height={size} viewBox="0 0 24 24" |
| fill="none" stroke="currentColor" strokeWidth="2" |
| strokeLinecap="round" strokeLinejoin="round" |
| style={{ display: 'block' }}> |
| {paths} |
| </svg> |
| ); |
| } |
|
|
| const SERIF = '"Source Serif 4", "GT Sectra", "Tiempos Headline", Charter, Georgia, serif'; |
| const SANS = '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; |
| const MONO = '"JetBrains Mono", "SF Mono", ui-monospace, monospace'; |
|
|
| function fmtDate(iso, opts = { y: true }) { |
| const d = new Date(iso + (iso.length === 10 ? 'T00:00:00Z' : '')); |
| if (opts.short) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); |
| return d.toLocaleDateString('en-US', { |
| year: opts.y ? 'numeric' : undefined, |
| month: 'short', |
| day: 'numeric', |
| }); |
| } |
|
|
| |
| |
| |
| const SUGGESTED = { |
| sarah: [ |
| 'When did her kidney function start declining?', |
| 'What medications was she on when CKD was diagnosed?', |
| 'Summarize her trajectory in 3 sentences.', |
| ], |
| marcus: [ |
| 'How long from first symptom to diagnosis?', |
| 'What was the response to R-CHOP?', |
| 'Summarize this patient\'s journey.', |
| ], |
| aisha: [ |
| 'What records does she have in foreign languages?', |
| 'Is her current anemia recurrent or new?', |
| 'What is her current pregnancy status?', |
| ], |
| demo: [ |
| 'When did her kidney function start declining?', |
| 'What was her first abnormal creatinine reading?', |
| 'What medications was she on when CKD was diagnosed?', |
| ], |
| }; |
|
|
| |
| function App() { |
| const [patients, setPatients] = useState([]); |
| const [patientId, setPatientId] = useState(null); |
| const [dark, setDark] = useState(false); |
| const [loading, setLoading] = useState(true); |
| const [error, setError] = useState(null); |
|
|
| useEffect(() => { |
| fetch('/api/patients') |
| .then((r) => r.json()) |
| .then((data) => { |
| setPatients(data); |
| if (data.length > 0) setPatientId(data[0].id); |
| setLoading(false); |
| }) |
| .catch((e) => { |
| setError(String(e)); |
| setLoading(false); |
| }); |
| }, []); |
|
|
| const c = dark ? PALETTE.dark : PALETTE.light; |
| const patient = useMemo( |
| () => patients.find((p) => p.id === patientId), |
| [patients, patientId], |
| ); |
|
|
| if (loading) { |
| return <Loading c={c} />; |
| } |
| if (error) { |
| return <ErrorView c={c} message={error} />; |
| } |
| if (!patient) { |
| return <ErrorView c={c} message="No patients available." />; |
| } |
|
|
| return ( |
| <div style={{ |
| position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', |
| background: c.bg, color: c.ink, fontFamily: SANS, fontSize: 13, |
| }}> |
| <Masthead c={c} dark={dark} patient={patient} allPatients={patients} |
| onPatientChange={setPatientId} |
| onDarkToggle={() => setDark(!dark)} /> |
| <div style={{ flex: 1, display: 'flex', minHeight: 0 }}> |
| <Document c={c} patient={patient} /> |
| <ChatColumn c={c} patient={patient} /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function Loading({ c }) { |
| return ( |
| <div style={{ |
| position: 'absolute', inset: 0, background: c.bg, color: c.muted, |
| display: 'grid', placeItems: 'center', fontFamily: SERIF, |
| }}> |
| <div style={{ textAlign: 'center' }}> |
| <div style={{ fontSize: 42, color: c.ink, letterSpacing: '-.03em' }}> |
| Recap<span style={{ color: c.accent }}>.</span> |
| </div> |
| <div style={{ fontStyle: 'italic', marginTop: 8 }}>loading the chart…</div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ErrorView({ c, message }) { |
| return ( |
| <div style={{ |
| position: 'absolute', inset: 0, background: c.bg, color: c.ink, |
| display: 'grid', placeItems: 'center', fontFamily: SERIF, padding: 24, |
| }}> |
| <div style={{ textAlign: 'center', maxWidth: 480 }}> |
| <div style={{ fontSize: 32, color: c.accent, letterSpacing: '-.02em' }}> |
| Something is off. |
| </div> |
| <div style={{ marginTop: 12, color: c.muted, fontStyle: 'italic' }}>{message}</div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function Masthead({ c, dark, patient, allPatients, onPatientChange, onDarkToggle }) { |
| const [open, setOpen] = useState(false); |
| return ( |
| <div style={{ |
| padding: '14px 28px', borderBottom: `1px solid ${c.rule}`, |
| background: c.bg, display: 'flex', alignItems: 'center', gap: 18, |
| }}> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 26, fontWeight: 500, |
| letterSpacing: '-0.025em', lineHeight: 1, color: c.ink, |
| }}> |
| Recap<span style={{ color: c.accent }}>.</span> |
| </div> |
| <div style={{ |
| paddingLeft: 16, borderLeft: `1px solid ${c.rule}`, |
| fontFamily: SERIF, fontStyle: 'italic', fontSize: 13.5, color: c.muted, |
| lineHeight: 1.3, maxWidth: 280, |
| }}> |
| Reads the whole chart so you don't have to. |
| </div> |
| <div style={{ flex: 1 }} /> |
| <div style={{ position: 'relative' }}> |
| <button onClick={() => setOpen(!open)} style={{ |
| display: 'flex', alignItems: 'center', gap: 10, |
| padding: '6px 12px', border: `1px solid ${c.rule}`, borderRadius: 2, |
| background: c.paper, color: c.ink, cursor: 'pointer', |
| fontFamily: SANS, fontSize: 12, |
| }}> |
| <span style={{ fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.06em' }}> |
| CASE № |
| </span> |
| <span style={{ fontFamily: SERIF, fontSize: 14, fontWeight: 500, color: c.ink }}> |
| {patient.display_name} |
| </span> |
| <span style={{ color: c.faint }}>▾</span> |
| </button> |
| {open && ( |
| <div style={{ |
| position: 'absolute', top: '110%', right: 0, width: 320, marginTop: 4, |
| background: c.paper, border: `1px solid ${c.rule}`, borderRadius: 2, |
| padding: 4, zIndex: 10, |
| boxShadow: dark ? '0 8px 32px rgba(0,0,0,.5)' : '0 8px 32px rgba(0,0,0,.12)', |
| }}> |
| {allPatients.map((p, i) => ( |
| <button key={p.id} |
| onClick={() => { onPatientChange(p.id); setOpen(false); }} |
| style={{ |
| width: '100%', textAlign: 'left', padding: '10px 12px', |
| borderRadius: 2, border: 'none', cursor: 'pointer', |
| background: p.id === patient.id ? c.accentSoft : 'transparent', |
| color: c.ink, fontFamily: 'inherit', |
| }}> |
| <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}> |
| <span style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.06em', |
| }}>{String(i + 1).padStart(2, '0')}</span> |
| <span style={{ fontFamily: SERIF, fontSize: 16, fontWeight: 500 }}> |
| {p.display_name} |
| </span> |
| {p.age != null && ( |
| <span style={{ fontSize: 11, color: c.muted }}>· {p.age}y</span> |
| )} |
| </div> |
| <div style={{ |
| fontSize: 11.5, color: c.muted, marginTop: 4, lineHeight: 1.45, |
| fontStyle: 'italic', fontFamily: SERIF, |
| }}> |
| {p.summary} |
| </div> |
| </button> |
| ))} |
| </div> |
| )} |
| </div> |
| <BackendBadge c={c} /> |
| <button onClick={onDarkToggle} style={{ |
| width: 28, height: 28, borderRadius: 2, |
| border: `1px solid ${c.rule}`, background: c.paper, |
| color: c.muted, cursor: 'pointer', fontSize: 14, |
| }}>{dark ? '☀' : '☾'}</button> |
| </div> |
| ); |
| } |
|
|
| function BackendBadge({ c }) { |
| const [info, setInfo] = useState({ backend: '...' }); |
| useEffect(() => { |
| fetch('/api/health').then((r) => r.json()).then(setInfo).catch(() => {}); |
| }, []); |
| return ( |
| <div style={{ |
| display: 'flex', alignItems: 'center', gap: 6, |
| padding: '4px 10px', border: `1px solid ${c.rule}`, borderRadius: 2, |
| background: c.paper, |
| fontFamily: MONO, fontSize: 10, color: c.muted, letterSpacing: '0.04em', |
| }}> |
| <span style={{ width: 5, height: 5, borderRadius: '50%', background: c.accent }} /> |
| AMD MI300X · 192 GB · {info.backend} |
| </div> |
| ); |
| } |
|
|
| |
| function Document({ c, patient }) { |
| const events = patient.events; |
| const groups = {}; |
| events.forEach((e) => { |
| const y = e.date.slice(0, 4); |
| (groups[y] = groups[y] || []).push(e); |
| }); |
| const years = Object.keys(groups).sort(); |
|
|
| return ( |
| <div style={{ |
| flex: 1.4, minWidth: 0, overflowY: 'auto', |
| background: c.paper, borderRight: `1px solid ${c.rule}`, |
| }}> |
| <div style={{ padding: '32px 36px 24px', borderBottom: `1px solid ${c.rule}` }}> |
| <div style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.12em', |
| textTransform: 'uppercase', marginBottom: 12, |
| }}> |
| Patient Dossier · {events.length} events · {years.length} year{years.length === 1 ? '' : 's'} on record |
| </div> |
| <h1 style={{ |
| fontFamily: SERIF, fontSize: 48, fontWeight: 500, |
| letterSpacing: '-0.03em', lineHeight: 1.05, color: c.ink, |
| margin: '0 0 12px', |
| }}> |
| {patient.display_name}<span style={{ color: c.accent }}>.</span> |
| </h1> |
| <div style={{ |
| fontFamily: SERIF, fontStyle: 'italic', fontSize: 18, color: c.muted, |
| lineHeight: 1.45, maxWidth: 620, textWrap: 'pretty', |
| }}> |
| {patient.summary} |
| </div> |
| <div style={{ display: 'flex', gap: 24, marginTop: 22, alignItems: 'baseline' }}> |
| {patient.age != null && <Stat c={c} value={patient.age} label="years old" />} |
| {patient.gender && <Stat c={c} value={patient.gender} label="gender" />} |
| {patient.mrn && <Stat c={c} value={patient.mrn} label="MRN" mono />} |
| <Stat c={c} value={new Set(events.map((e) => e.source)).size} label="source docs" /> |
| </div> |
| {patient.tags && patient.tags.length > 0 && ( |
| <div style={{ display: 'flex', gap: 8, marginTop: 20, flexWrap: 'wrap' }}> |
| {patient.tags.map((t) => ( |
| <span key={t} style={{ |
| fontFamily: SANS, fontSize: 11, color: c.inkSoft, |
| padding: '3px 10px', border: `1px solid ${c.rule}`, borderRadius: 2, |
| background: c.bg, |
| }}>{t}</span> |
| ))} |
| </div> |
| )} |
| </div> |
| |
| <div style={{ padding: '24px 36px 48px' }}> |
| {years.map((y, yi) => ( |
| <YearSection key={y} c={c} year={y} events={groups[y]} first={yi === 0} /> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function Stat({ c, value, label, mono }) { |
| return ( |
| <div> |
| <div style={{ |
| fontFamily: mono ? MONO : SERIF, |
| fontSize: mono ? 14 : 22, fontWeight: 500, color: c.ink, lineHeight: 1, |
| }}>{value}</div> |
| <div style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', marginTop: 5, |
| }}>{label}</div> |
| </div> |
| ); |
| } |
|
|
| function YearSection({ c, year, events, first }) { |
| const [activeId, setActiveId] = useState(null); |
| return ( |
| <div style={{ position: 'relative', marginBottom: 32 }}> |
| <div style={{ |
| display: 'flex', alignItems: 'baseline', gap: 14, |
| marginBottom: 8, paddingBottom: 8, |
| borderBottom: `1px solid ${c.ruleSoft}`, marginLeft: 80, |
| }}> |
| <h2 style={{ |
| fontFamily: SERIF, fontSize: 32, fontWeight: 500, letterSpacing: '-0.02em', |
| color: c.ink, margin: 0, lineHeight: 1, |
| }}>{year}</h2> |
| <div style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', |
| }}> |
| {events.length} {events.length === 1 ? 'event' : 'events'} |
| </div> |
| </div> |
| |
| <div style={{ |
| position: 'absolute', left: 100, top: 56, bottom: 0, |
| width: 1, background: c.rule, |
| }} /> |
| |
| {events.map((e, ei) => ( |
| <DocEvent key={e.id} c={c} e={e} index={ei} |
| active={activeId === e.id} |
| onClick={() => setActiveId(activeId === e.id ? null : e.id)} /> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function DocEvent({ c, e, index, active, onClick }) { |
| const cat = CAT[e.category] || CAT.other; |
| const [iconHover, setIconHover] = useState(false); |
| |
| |
| |
| const iconCenterY = 26; |
| const iconSize = active ? 30 : 26; |
| const iconPadTop = Math.max(iconCenterY - iconSize / 2, 0); |
| return ( |
| <div style={{ |
| display: 'flex', alignItems: 'flex-start', |
| padding: '12px 0', position: 'relative', |
| }}> |
| <div style={{ |
| width: 76, flexShrink: 0, paddingRight: 8, |
| paddingTop: 16, textAlign: 'right', |
| }}> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 14, color: c.ink, fontWeight: 500, |
| letterSpacing: '-0.01em', |
| }}> |
| {fmtDate(e.date, { y: false, short: true })} |
| </div> |
| <div style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.06em', |
| marginTop: 2, |
| }}> |
| {String(index + 1).padStart(2, '0')} |
| </div> |
| </div> |
| |
| <div style={{ |
| width: 32, flexShrink: 0, display: 'flex', justifyContent: 'center', |
| paddingTop: iconPadTop, position: 'relative', zIndex: 1, |
| }}> |
| {/* Hover wrapper sits exactly on the icon — tooltip uses bottom:100% relative to it */} |
| <div style={{ position: 'relative', display: 'inline-block' }} |
| onMouseEnter={() => setIconHover(true)} |
| onMouseLeave={() => setIconHover(false)}> |
| <div style={{ |
| width: iconSize, height: iconSize, |
| borderRadius: e.category === 'diagnosis' ? 4 : '50%', |
| background: active ? c.accent : c.paper, |
| border: `1px solid ${active ? c.accent : c.rule}`, |
| display: 'grid', placeItems: 'center', |
| color: active ? c.paper : c.muted, |
| transition: 'all .15s', |
| boxShadow: iconHover && !active ? `0 0 0 4px ${c.accentSoft}` : 'none', |
| }}> |
| <EventIcon category={e.category} size={active ? 16 : 14} /> |
| </div> |
| {iconHover && ( |
| <div role="tooltip" style={{ |
| position: 'absolute', bottom: '100%', left: '50%', |
| transform: 'translateX(-50%)', marginBottom: 8, |
| background: c.ink, color: c.bg, |
| padding: '6px 10px', borderRadius: 2, |
| fontFamily: MONO, fontSize: 10, letterSpacing: '0.06em', |
| whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50, |
| boxShadow: '0 4px 14px rgba(0,0,0,.18)', |
| display: 'flex', alignItems: 'baseline', gap: 6, |
| }}> |
| <span style={{ fontWeight: 600, textTransform: 'uppercase' }}> |
| {cat.label} |
| </span> |
| <span style={{ opacity: 0.65 }}>· {cat.hint}</span> |
| {/* Tooltip tail */} |
| <span style={{ |
| position: 'absolute', top: '100%', left: '50%', |
| transform: 'translateX(-50%)', |
| width: 0, height: 0, |
| borderLeft: '5px solid transparent', |
| borderRight: '5px solid transparent', |
| borderTop: `5px solid ${c.ink}`, |
| }} /> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <button onClick={onClick} style={{ |
| flex: 1, marginLeft: 18, textAlign: 'left', |
| background: active ? c.accentSoft : 'transparent', |
| padding: active ? '10px 14px' : '0', |
| marginTop: active ? -4 : 0, marginBottom: active ? -4 : 0, |
| border: 'none', cursor: 'pointer', color: c.ink, fontFamily: 'inherit', |
| borderRadius: 2, |
| }}> |
| <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}> |
| <span style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, |
| textTransform: 'uppercase', letterSpacing: '0.1em', |
| }}>{e.category}</span> |
| {e.flag === 'critical' && ( |
| <span style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.accent, |
| textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, |
| }}>· Critical</span> |
| )} |
| {(e.flag === 'high' || e.flag === 'low') && ( |
| <span style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.mark, |
| textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 600, |
| }}>· {e.flag === 'high' ? 'High' : 'Low'}</span> |
| )} |
| </div> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 17, fontWeight: 500, color: c.ink, |
| letterSpacing: '-0.012em', lineHeight: 1.3, marginTop: 3, textWrap: 'balance', |
| }}>{e.title}</div> |
| {e.body && e.body !== e.title && (active || e.flag === 'critical') && ( |
| <div style={{ |
| fontFamily: SERIF, fontSize: 14, color: c.inkSoft, lineHeight: 1.5, |
| marginTop: 6, fontStyle: 'italic', maxWidth: 540, textWrap: 'pretty', |
| }}>{e.body}</div> |
| )} |
| {active && ( |
| <div style={{ |
| marginTop: 10, paddingTop: 8, borderTop: `1px solid ${c.rule}`, |
| fontFamily: MONO, fontSize: 10.5, color: c.muted, |
| display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', |
| }}> |
| <span>Source: <span style={{ color: c.ink }}>{e.source}</span></span> |
| {e.page && <span>Page {e.page}</span>} |
| {e.snippet && ( |
| <span style={{ |
| fontStyle: 'italic', fontFamily: SERIF, fontSize: 12.5, color: c.inkSoft, |
| }}>"{e.snippet}"</span> |
| )} |
| </div> |
| )} |
| </button> |
| </div> |
| ); |
| } |
|
|
| |
| function ChatColumn({ c, patient }) { |
| const [history, setHistory] = useState([]); |
| const [input, setInput] = useState(''); |
| const [thinking, setThinking] = useState(false); |
| const scroller = useRef(null); |
|
|
| useEffect(() => { |
| setHistory([]); |
| setInput(''); |
| }, [patient.id]); |
|
|
| useEffect(() => { |
| if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight; |
| }, [history, thinking]); |
|
|
| const send = async (text) => { |
| const q = (text || input).trim(); |
| if (!q) return; |
| setInput(''); |
| setHistory((h) => [...h, { role: 'user', text: q }]); |
| setThinking(true); |
| try { |
| const r = await fetch('/api/answer', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ patient_id: patient.id, question: q }), |
| }); |
| const data = await r.json(); |
| if (data.error) { |
| setHistory((h) => [...h, { role: 'assistant', text: `Error: ${data.error}`, citations: [] }]); |
| } else { |
| setHistory((h) => [...h, { |
| role: 'assistant', |
| text: data.text, |
| citations: data.citations || [], |
| }]); |
| } |
| } catch (err) { |
| setHistory((h) => [...h, { |
| role: 'assistant', |
| text: `Network error: ${String(err)}`, |
| citations: [], |
| }]); |
| } finally { |
| setThinking(false); |
| } |
| }; |
|
|
| const examples = SUGGESTED[patient.id] || SUGGESTED.demo || []; |
|
|
| return ( |
| <div style={{ |
| flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: c.bg, |
| }}> |
| <div style={{ padding: '20px 24px 14px', borderBottom: `1px solid ${c.rule}` }}> |
| <div style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.12em', |
| textTransform: 'uppercase', marginBottom: 6, |
| }}> |
| The Reading Room |
| </div> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 22, fontWeight: 500, |
| letterSpacing: '-0.02em', color: c.ink, lineHeight: 1.15, |
| }}> |
| Ask a question.<br /> |
| <span style={{ fontStyle: 'italic', color: c.muted }}>Get a cited answer.</span> |
| </div> |
| </div> |
| |
| <div ref={scroller} style={{ flex: 1, overflowY: 'auto', padding: '18px 24px' }}> |
| {history.length === 0 && ( |
| <div> |
| <div style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', marginBottom: 12, |
| }}>Suggested</div> |
| {examples.map((ex, i) => ( |
| <button key={ex} onClick={() => send(ex)} style={{ |
| display: 'flex', gap: 12, alignItems: 'flex-start', |
| width: '100%', textAlign: 'left', |
| padding: '14px 0', |
| borderTop: i === 0 ? `1px solid ${c.rule}` : 'none', |
| borderBottom: `1px solid ${c.rule}`, |
| background: 'transparent', border: 'none', cursor: 'pointer', |
| borderRadius: 0, color: c.ink, fontFamily: 'inherit', |
| }}> |
| <span style={{ |
| fontFamily: MONO, fontSize: 10, color: c.faint, letterSpacing: '0.1em', |
| width: 22, paddingTop: 4, flexShrink: 0, |
| }}>0{i + 1}</span> |
| <span style={{ |
| flex: 1, fontFamily: SERIF, fontSize: 16, lineHeight: 1.4, |
| color: c.ink, fontWeight: 500, letterSpacing: '-0.01em', |
| }}>{ex}</span> |
| <span style={{ color: c.accent, fontSize: 16, marginTop: 1 }}>→</span> |
| </button> |
| ))} |
| </div> |
| )} |
| |
| {history.map((m, i) => ( |
| <div key={i} style={{ marginBottom: 22 }}> |
| {m.role === 'user' ? ( |
| <div> |
| <div style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', marginBottom: 6, |
| }}>You asked</div> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 19, lineHeight: 1.35, color: c.ink, |
| fontWeight: 500, letterSpacing: '-0.015em', |
| }}>"{m.text}"</div> |
| </div> |
| ) : ( |
| <AssistantMessage c={c} m={m} patient={patient} /> |
| )} |
| </div> |
| ))} |
| |
| {thinking && ( |
| <div style={{ |
| fontFamily: MONO, fontSize: 11, color: c.muted, letterSpacing: '0.04em', |
| padding: '8px 0', |
| }}> |
| <span style={{ animation: 'edit-blink 1s infinite' }}>▌</span> |
| {' '}reading {patient.events.length} events… |
| </div> |
| )} |
| </div> |
| |
| <div style={{ padding: '14px 24px 18px', borderTop: `1px solid ${c.rule}` }}> |
| <div style={{ |
| display: 'flex', alignItems: 'center', gap: 10, |
| border: `1px solid ${c.rule}`, borderRadius: 2, |
| background: c.paper, padding: '10px 14px', |
| }}> |
| <span style={{ |
| fontFamily: SERIF, color: c.accent, fontSize: 18, fontStyle: 'italic', |
| }}>?</span> |
| <input value={input} onChange={(e) => setInput(e.target.value)} |
| onKeyDown={(e) => e.key === 'Enter' && send()} |
| placeholder="Ask anything about this chart…" |
| style={{ |
| flex: 1, border: 'none', background: 'transparent', |
| color: c.ink, fontSize: 14, outline: 'none', |
| fontFamily: SERIF, padding: '2px 0', |
| }} /> |
| <button onClick={() => send()} style={{ |
| padding: '6px 14px', borderRadius: 2, |
| background: c.accent, border: 'none', color: c.paper, |
| cursor: 'pointer', fontSize: 12, fontWeight: 500, |
| fontFamily: SANS, letterSpacing: '0.02em', |
| }}>Ask →</button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function AssistantMessage({ c, m, patient }) { |
| |
| const citationsByKey = {}; |
| let counter = 0; |
| const text = (m.text || '').replace(/\[src:([^\]#]+)(?:#p(\d+))?\]/g, (_match, src, page) => { |
| const key = `${src}|${page || ''}`; |
| if (!(key in citationsByKey)) { |
| counter += 1; |
| citationsByKey[key] = { n: counter, src, page: page ? parseInt(page, 10) : null }; |
| } |
| return `‹CITE:${citationsByKey[key].n}›`; |
| }); |
|
|
| |
| const segments = text.split(/(‹CITE:\d+›|\*\*[^*]+\*\*)/g); |
| return ( |
| <div> |
| <div style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', marginBottom: 8, |
| }}>The chart says</div> |
| <div style={{ |
| fontFamily: SERIF, fontSize: 16.5, lineHeight: 1.55, color: c.ink, |
| letterSpacing: '-0.005em', textWrap: 'pretty', |
| }}> |
| {segments.map((seg, i) => { |
| if (seg.startsWith('‹CITE:')) { |
| const n = parseInt(seg.slice(6, -1), 10); |
| return ( |
| <sup key={i} style={{ |
| color: c.accent, fontFamily: SERIF, fontStyle: 'italic', |
| fontWeight: 700, fontSize: 11, padding: '0 2px', |
| }}>{n}</sup> |
| ); |
| } |
| if (seg.startsWith('**') && seg.endsWith('**')) { |
| return <strong key={i} style={{ fontWeight: 600 }}>{seg.slice(2, -2)}</strong>; |
| } |
| return <React.Fragment key={i}>{seg}</React.Fragment>; |
| })} |
| </div> |
| |
| {(m.citations && m.citations.length > 0) && ( |
| <div style={{ marginTop: 16, paddingTop: 12, borderTop: `1px solid ${c.rule}` }}> |
| <div style={{ |
| fontFamily: MONO, fontSize: 9.5, color: c.faint, letterSpacing: '0.1em', |
| textTransform: 'uppercase', marginBottom: 8, |
| }}>Drawn from</div> |
| {m.citations.map((cit, i) => ( |
| <div key={i} style={{ |
| display: 'flex', alignItems: 'baseline', gap: 12, padding: '6px 0', |
| }}> |
| <span style={{ |
| fontFamily: SERIF, fontStyle: 'italic', color: c.accent, |
| fontSize: 14, width: 18, flexShrink: 0, |
| }}>{i + 1}.</span> |
| <span style={{ flex: 1, fontSize: 12.5, lineHeight: 1.45 }}> |
| {cit.snippet && ( |
| <span style={{ fontFamily: SERIF, color: c.ink, fontWeight: 500 }}> |
| {cit.snippet} |
| </span> |
| )} |
| {cit.snippet && <span style={{ color: c.muted }}> · </span>} |
| <span style={{ fontFamily: MONO, fontSize: 10.5, color: c.muted }}> |
| {cit.source_id}{cit.page ? ` p.${cit.page}` : ''} |
| </span> |
| </span> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| if (!document.getElementById('edit-keyframes')) { |
| const s = document.createElement('style'); |
| s.id = 'edit-keyframes'; |
| s.textContent = `@keyframes edit-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }`; |
| document.head.appendChild(s); |
| } |
|
|
| ReactDOM.createRoot(document.getElementById('root')).render(<App />); |
|
|