import { useEffect, useState } from "react"; import { API_BASE } from "./config"; // Project workspace: a project groups chats and RAG documents. The active // project is persisted in settings, and chat scopes its document retrieval to // it (see synapse/main.py chat_stream_endpoint). "" = unscoped / All. const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" }; const card = { padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px", display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.6rem" }; const btn = { padding: "0.55rem 1rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" }; const ghost = { padding: "0.55rem 1rem", background: "#1a1a1a", color: "#ccc", border: "1px solid #333", borderRadius: "8px", cursor: "pointer" }; const danger = { padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }; function Modal({ title, onClose, children }) { return (
e.stopPropagation()} style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
{title}
{children}
); } export function Projects({ onOpenChat, onNewChat }) { const [docs, setDocs] = useState([]); const [chats, setChats] = 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} preview const [uploading, setUploading] = useState(false); // upload modal open const [picking, setPicking] = useState(null); // conversations available to add const [projects, setProjects] = useState([]); const [activeProject, setActiveProject] = useState(""); // "" = All const [instructions, setInstructions] = useState(""); // per-project system prompt const [savedInstructions, setSavedInstructions] = useState(""); const [facts, setFacts] = useState([]); // memory scoped to this project const [newFact, setNewFact] = useState(""); const [dragging, setDragging] = useState(false); // file hovering the drop zone const loadProjects = async () => { try { const r = await fetch(`${API_BASE}/projects`); if (!r.ok) return; const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); const mine = (d.projects || []).find(p => p.id === (d.active || "")); setInstructions(mine?.instructions || ""); setSavedInstructions(mine?.instructions || ""); } catch { /* offline */ } }; const load = async (pid) => { const scope = pid ?? activeProject; if (!scope) { setDocs([]); setChats([]); setFacts([]); return; } try { const r = await fetch(`${API_BASE}/documents`); if (r.ok) setDocs((await r.json()).documents || []); const c = await fetch(`${API_BASE}/conversations?project=${encodeURIComponent(scope)}`); if (c.ok) setChats((await c.json()).conversations || []); const m = await fetch(`${API_BASE}/memory?project=${encodeURIComponent(scope)}`); if (m.ok) setFacts((await m.json()).items || []); } catch { /* offline — leave lists as-is */ } }; useEffect(() => { loadProjects().then(() => {}); }, []); useEffect(() => { load(activeProject); }, [activeProject]); // eslint-disable-line react-hooks/exhaustive-deps // Switch the active workspace (persisted in settings; scopes chat RAG and // any new conversation started from here). const setActive = async (pid) => { await fetch(`${API_BASE}/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ active_project: pid }), }); setActiveProject(pid); loadProjects(); }; const newProject = async () => { const name = window.prompt("New project name:"); if (!name || !name.trim()) return; const r = await fetch(`${API_BASE}/projects`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name.trim() }), }); if (r.ok) { const p = await r.json(); await setActive(p.id); } }; const removeProject = async () => { if (!activeProject) return; if (!window.confirm("Delete this project? Its chats and documents are kept but become unscoped.")) return; await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" }); await setActive(""); }; // --- instructions -------------------------------------------------------- const saveInstructions = async () => { await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ instructions }), }); setSavedInstructions(instructions); loadProjects(); }; // --- memory facts scoped to this project --------------------------------- const addFact = async () => { const text = newFact.trim(); if (!text) return; await fetch(`${API_BASE}/memory`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, section: "Projects", project_id: activeProject }), }); setNewFact(""); load(); }; const removeFact = async (id) => { await fetch(`${API_BASE}/memory/${encodeURIComponent(id)}`, { method: "DELETE" }); load(); }; // --- chats --------------------------------------------------------------- const moveChat = async (id, pid) => { await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: pid }), }); load(); }; const openPicker = async () => { const r = await fetch(`${API_BASE}/conversations`); if (r.ok) setPicking(((await r.json()).conversations || []).filter(c => c.project_id !== activeProject)); }; const addChat = async (id) => { await moveChat(id, activeProject); setPicking(p => (p || []).filter(c => c.id !== id)); }; // --- documents ----------------------------------------------------------- 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 */ } }; // 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) uploadFile(file); }; // Resolves when the file is indexed, so a multi-file drop uploads in order // instead of racing (each one re-renders the list on completion). const uploadFile = (file) => new Promise((resolve) => { 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); resolve(); } }; reader.readAsDataURL(file); }); const onDrop = async (e) => { e.preventDefault(); setDragging(false); if (!activeProject) return; const files = [...(e.dataTransfer?.files || [])]; if (!files.length) return; setUploading(true); for (const f of files) await uploadFile(f); }; 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 label = (c) => c.title || c.preview || "Untitled chat"; return (
{ e.preventDefault(); if (activeProject) setDragging(true); }} onDragLeave={e => { if (e.currentTarget === e.target) setDragging(false); }} onDrop={onDrop} style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px", position: "relative", outline: dragging ? "2px dashed #007acc" : "none", borderRadius: "12px" }} > {!activeProject ? ( <>

📁 Projects

{projects.length === 0 ? (
No projects. A project keeps its own chats, documents, memory and instructions.
) : (
{projects.map(p => (
setActive(p.id)} style={{ ...card, cursor: "pointer" }} title="Open project">
{p.name}
{p.chats} chat{p.chats === 1 ? "" : "s"} · {p.docs} document{p.docs === 1 ? "" : "s"}
))}
)} ) : ( <>

{projects.find(p => p.id === activeProject)?.name || "Project"}

{dragging && (
Drop files to index them in this project.
)}

How the assistant should act here