/* ============================================
WORKSPACE — Chat (RAG) + Document Viewer with bbox overlay
============================================ */
function WorkspaceScreen({ onNavigate }) {
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [messages, setMessages] = useState([]);
const [citations, setCitations] = useState([]);
const [activeIndex, setActiveIndex] = useState(null);
const [error, setError] = useState(null);
const scrollRef = useRef(null);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages]);
// Auto-select citation [1] whenever a new set arrives.
useEffect(() => {
if (citations.length > 0) setActiveIndex(1);
else setActiveIndex(null);
}, [citations]);
const send = async () => {
const prompt = input.trim();
if (!prompt || sending) return;
setError(null);
setInput("");
setSending(true);
setMessages((m) => [...m, { role: "user", text: prompt }, { role: "assistant", text: "" }]);
setCitations([]);
try {
const res = await window.API.chat(prompt);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop();
for (const evt of events) {
if (evt.startsWith("event: done")) continue;
if (!evt.startsWith("data: ")) continue;
const dataStr = evt.slice(6);
if (dataStr === "[DONE]") continue;
let payload;
try { payload = JSON.parse(dataStr); } catch (_) { continue; }
if (payload.citations) {
setCitations(payload.citations);
} else if (payload.token) {
setMessages((m) => {
const next = [...m];
const last = next[next.length - 1];
next[next.length - 1] = { ...last, text: last.text + payload.token };
return next;
});
} else if (payload.error) {
setError(payload.error);
}
}
}
} catch (err) {
setError(err.message || "Chat failed.");
} finally {
setSending(false);
}
};
const reset = () => {
setMessages([]);
setCitations([]);
setActiveIndex(null);
setError(null);
};
const activeCitation = citations.find((c) => c.index === activeIndex) || null;
return (
{/* ===== LEFT: Chat panel ===== */}
Ask the indexed manuals
{messages.length === 0 ? "No messages yet" : `${messages.length} message${messages.length === 1 ? "" : "s"} · ${citations.length} sources in scope`}
{messages.length === 0 && (
Ask a question about your ingested manuals.
Citations appear as clickable pills — click one to see the source page.
)}
{messages.map((msg, i) => (
{msg.role === "assistant" ? (
<>
{!msg.text && sending && i === messages.length - 1 && …}
>
) : msg.text}
))}
{error && (
{error}
)}
{/* Composer */}
Responses grounded in the indexed manuals. Verify safety-critical values against the printed copy.
{/* ===== RIGHT: Document Viewer ===== */}
Document Viewer
{activeCitation
? [{activeCitation.index}] {activeCitation.source} · p.{activeCitation.page}
: Send a question to see the cited page.}
{citations.length === 0 && (
Cited pages will render here after each query.
)}
{activeCitation && (
)}
{citations.length > 0 && (
All retrieved sources ({citations.length})
{citations.map((c) => (
))}
)}
);
}
function DocumentPage({ citation }) {
const { task_id, page, bbox, text } = citation;
const imgUrl = task_id ? `/api/document/${task_id}/page/${page}.png` : null;
const token = window.API.getToken();
// FileResponse via FastAPI honors Bearer in standard fetch, but
// can't attach Authorization headers. Workaround: use a fetch + blob URL.
const [blobUrl, setBlobUrl] = useState(null);
const [imgError, setImgError] = useState(null);
useEffect(() => {
if (!imgUrl) return;
let cancel = false;
let createdUrl = null;
(async () => {
try {
const res = await fetch(imgUrl, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
if (!res.ok) throw new Error(`Page image not found (${res.status})`);
const blob = await res.blob();
if (cancel) return;
createdUrl = URL.createObjectURL(blob);
setBlobUrl(createdUrl);
} catch (err) {
if (!cancel) setImgError(err.message);
}
})();
return () => {
cancel = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [imgUrl, token]);
return (
{imgError && (
{imgError}
)}
{!imgError && !blobUrl && (
Loading page…
)}
{blobUrl && (

{bbox && (
)}
)}
{text && (
)}
);
}
// Renders assistant Markdown text with [file.pdf p.N] citation markers turned
// into clickable orange pills that switch the Document Viewer.
const CITE_PATTERN = /\[([^\[\]]+?\.pdf)\s+p\.([\d.-]+)\]/g;
function MarkdownWithCitations({ text, citations, onCite }) {
const containerRef = React.useRef(null);
const citeMapRef = React.useRef([]);
const html = React.useMemo(() => {
if (!text) return "";
citeMapRef.current = [];
// Replace citation markers with styled