// Atlas · Step 1 — Brief const { useState, useRef, useEffect } = React; const stripHtml = (html) => (html || "").replace(/<[^>]+>/g, "").replace(/ /g, " ").trim(); function StepIntake({ state, setState, goNext, goHome, goForward, loading, error, readOnly }) { const { REPORT_TYPES, FORMATS, AUDIENCES } = window.ATLAS_DATA; const briefRef = useRef(null); const fileInputRef = useRef(null); const [urlInput, setUrlInput] = useState(""); const [urlLoading, setUrlLoading] = useState(false); const [attErrors, setAttErrors] = useState({}); // key: name|url → error string const [attUploading, setAttUploading] = useState(new Set()); const setAttError = (key, msg) => setAttErrors(prev => ({ ...prev, [key]: msg })); const clearAttError = (key) => setAttErrors(prev => { const n = { ...prev }; delete n[key]; return n; }); const attachments = state.attachments || []; const pdfCount = attachments.filter(a => a.type === "pdf").length; const imageCount = attachments.filter(a => a.type === "image").length; const urlCount = attachments.filter(a => a.type === "url").length; const pdfAtLimit = pdfCount >= 1; const imageAtLimit = imageCount >= 2; const urlAtLimit = urlCount >= 2; const handleFileUpload = async (files) => { for (const file of Array.from(files)) { const key = file.name; clearAttError(key); // Client-side size pre-check const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name); const cap = isImage ? 5 * 1024 * 1024 : 10 * 1024 * 1024; if (isImage && imageAtLimit) { setAttError(key, "Image limit reached (2 max). Remove one to add another."); continue; } if (!isImage && pdfAtLimit) { setAttError(key, "Only 1 PDF supported per report."); continue; } const allowedExt = /\.(pdf|jpg|jpeg|png|gif|webp)$/i.test(file.name); if (!allowedExt) { setAttError(key, "Unsupported file type. Accepted: PDF, JPG, PNG, GIF, WebP."); continue; } if (file.size > cap) { const limitMb = cap / (1024 * 1024); setAttError(key, isImage ? `Image too large — max ${limitMb} MB per image.` : `File too large — PDFs and documents are capped at ${limitMb} MB. Try splitting the file or pasting the key section into the brief.` ); continue; } setAttUploading(prev => new Set(prev).add(key)); try { const form = new FormData(); form.append("file", file); const resp = await fetch("/api/upload", { method: "POST", body: form }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); setAttError(key, data.detail || "Upload failed. Please try again."); continue; } const data = await resp.json(); const att = { name: data.filename, path: data.path, type: data.type, ...(data.media_type ? { media_type: data.media_type } : {}), ...(data.truncated_pages ? { truncated_pages: true, total_pages: data.total_pages } : {}), }; setState(prev => ({ ...prev, attachments: [...(prev.attachments || []), att] })); } catch { setAttError(key, "Upload failed. Please try again."); } finally { setAttUploading(prev => { const n = new Set(prev); n.delete(key); return n; }); } } if (fileInputRef.current) fileInputRef.current.value = ""; }; const handleAddUrl = async () => { const url = urlInput.trim(); if (!url) return; if (urlAtLimit) return; const key = url; clearAttError(key); if (!/^https?:\/\//i.test(url)) { setAttError(key, "This URL can't be accessed — only public web addresses are supported."); return; } setUrlLoading(true); try { const resp = await fetch("/api/fetch-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), }); const data = await resp.json(); if (!data.ok) { setAttError(key, "This URL can't be accessed — only public web addresses are supported."); return; } const att = { name: new URL(url).hostname, url, type: "url", extracted_text: data.extracted_text }; setState(prev => ({ ...prev, attachments: [...(prev.attachments || []), att] })); setUrlInput(""); } catch { setAttError(key, "Couldn't retrieve this URL. Check that it's publicly accessible and try again."); } finally { setUrlLoading(false); } }; const removeAttachment = (idx) => { setState(prev => { const next = (prev.attachments || []).filter((_, i) => i !== idx); return { ...prev, attachments: next }; }); }; useEffect(() => { if (briefRef.current) briefRef.current.innerHTML = state.brief || ""; }, []); // run only on mount — restores brief when navigating back const briefPlain = stripHtml(state.brief); const wordCount = briefPlain.split(/\s+/).filter(Boolean).length; const valid = state.title.trim().length > 2 && state.reportType && state.format && briefPlain.length > 40 && wordCount <= 750 && state.audience && (state.reportType !== "position" || (state.anchorEntity || "").trim().length > 1); const execBriefCmd = (cmd) => { document.execCommand(cmd, false, null); briefRef.current?.focus(); if (briefRef.current) setState({ ...state, brief: briefRef.current.innerHTML }); }; const formatSize = (bytes) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; return (
Atlas will anchor the entire report on this entity and benchmark it against peers.
setState({ ...state, anchorEntity: e.target.value })} placeholder="e.g. Acme Bio — GalNAc-conjugated siRNA platform" style={{ width: "100%", fontSize: 14 }} />Write freely. Include questions to answer, parameters every entry should capture, references to mirror, and anything that's out of scope. Atlas will structure it in the next step.
Attach PDFs, images, or URLs to give the scoping agent richer context. Content is read at brief-submission time and influences the questions and themes generated.
{!readOnly && (Defaults that bound the search. Override at any step.
The questions the report must answer.
Parameters every entry should capture (e.g. modality, stage, sponsor).
Prior reports or papers to mirror in tone and depth.
Anything that's explicitly out of scope.
Atlas pauses for sign-off at scope, outline, and draft review before continuing.