Files
NexusOS/interface/web/src/Documents.jsx
T
jonandClaude Opus 4.8 ac0eb1e5f6 feat(rag): PDF/docx ingest + chat citations
- Upload endpoint (base64 JSON, no multipart dep): extracts text from
  pdf/docx/txt/md via pypdf + python-docx, then runs the existing
  chunk/embed pipeline. Documents page uploads files straight through.
- Citations: the chat stream emits an SSE `sources` event listing the
  documents that fed the answer; the UI shows them as chips under the reply.
- Deps: pypdf, python-docx (both pure-Python, Windows-safe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:48:32 -05:00

169 lines
7.3 KiB
React

import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// RAG document manager: upload/paste text, chunked + embedded server-side, then
// retrieved into the chat system prompt. See synapse/memory/store.py.
export function Documents() {
const [docs, setDocs] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
const load = async () => {
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON — no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
setBusy(true);
setMessage(`Reading ${file.name}…`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
reader.readAsDataURL(file);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px" }}>
<h2 style={{ marginTop: 0 }}>📄 Documents</h2>
<p style={{ color: "#aaa", marginTop: 0 }}>
Upload or paste text. It's chunked, embedded, and pulled into chat as source
material when a message is relevant.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or upload a .pdf/.docx/.txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px" }}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)}
style={{ padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Delete
</button>
</div>
))}
</div>
)}
{viewing && (
<div onClick={() => setViewing(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{viewing.title}</strong>
<button onClick={() => setViewing(null)}
style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}