forked from enderofwings/NexusOS
- 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>
169 lines
7.3 KiB
React
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>
|
|
);
|
|
}
|