Files
NexusOS/interface/web/src/Projects.jsx
T
janvanwan 42eaed647a feat: sync with upstream — v1.2.0, in-app updates, Projects, modules
Brings the public tree back in line with the development repo after several
weeks of drift caused by a stale publish include list.

New:
- In-app update path: GET /update/check compares the checkout against
  origin/main and POST /update/apply runs `ncp upgrade` detached (pull,
  rebuild, restart). The sidebar shows the version, checks on click, and
  offers an "update available" pill.
- Projects: a project workspace groups chats and RAG documents, with
  per-project instructions and document retrieval scoped to the active
  project. Replaces the standalone Documents page.
- modules/: auto-discovered feature plugins (mail, network) with their
  frontend counterparts and tests.
- Memory curation runs in-process (synapse/memory/curator.py) on the chat
  model when a conversation goes idle. The separate memory service on :8001
  is gone, along with the launcher lines that started it.

Also: the KDE theme, panel and Promethean terminal assets, the full test
suite, and VERSION 1.2.0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-25 09:13:55 -05:00

431 lines
19 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div onClick={onClose}
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>{title}</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>{children}</div>
</div>
</div>
);
}
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 (
<div
onDragOver={e => { 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 ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, flex: 1 }}>📁 Projects</h2>
<button onClick={newProject} style={btn}> New project</button>
</div>
{projects.length === 0 ? (
<div style={{ color: "#777" }}>
No projects. A project keeps its own chats, documents, memory and instructions.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{projects.map(p => (
<div key={p.id} onClick={() => setActive(p.id)} style={{ ...card, cursor: "pointer" }} title="Open project">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>
{p.chats} chat{p.chats === 1 ? "" : "s"} · {p.docs} document{p.docs === 1 ? "" : "s"}
</div>
</div>
<span style={{ color: "#555" }}></span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.4rem" }}>
<button onClick={() => setActive("")} title="Back to all projects" style={ghost}> Projects</button>
<h2 style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projects.find(p => p.id === activeProject)?.name || "Project"}
</h2>
<button onClick={removeProject} title="Delete this project (chats and documents kept)" style={danger}>
Delete project
</button>
</div>
{dragging && (
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0d2233", border: "1px solid #007acc",
borderRadius: "10px", padding: "0.7rem 1rem", marginBottom: "0.8rem", color: "#8ab4ff" }}>
Drop files to index them in this project.
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>How the assistant should act here</h3>
<textarea rows={4} value={instructions} onChange={e => setInstructions(e.target.value)}
placeholder="e.g. Answer as a build engineer. Prefer bash over Python. Always cite the doc you used."
style={input} />
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginTop: "0.5rem" }}>
<button onClick={saveInstructions} disabled={instructions === savedInstructions}
style={{ ...btn, background: instructions === savedInstructions ? "#333" : "#007acc",
cursor: instructions === savedInstructions ? "default" : "pointer" }}>
Save instructions
</button>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Layered under the active playbook, for chats in this project only.
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Chats</h3>
<button onClick={onNewChat} style={btn}> New chat</button>
<button onClick={openPicker} style={ghost}>Add existing</button>
</div>
{chats.length === 0 ? (
<div style={{ color: "#777", marginBottom: "1.5rem" }}>No chats in this project yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginBottom: "1.5rem" }}>
{chats.map(c => (
<div key={c.id} style={card}>
<div onClick={() => onOpenChat(c.id)} style={{ cursor: "pointer", flex: 1, minWidth: 0 }} title="Open in chat">
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{new Date(c.updated_at * 1000).toLocaleString()}</div>
</div>
<button onClick={() => moveChat(c.id, "")} title="Remove from project (chat kept)" style={danger}>Remove</button>
</div>
))}
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>Project memory</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{facts.map(f => (
<div key={f.id} style={card}>
<span style={{ flex: 1, minWidth: 0, color: "#ddd", fontSize: "0.9rem" }}>{f.text}</span>
<button onClick={() => removeFact(f.id)} style={danger}>Delete</button>
</div>
))}
<div style={{ display: "flex", gap: "0.6rem" }}>
<input type="text" value={newFact} onChange={e => setNewFact(e.target.value)}
onKeyDown={e => e.key === "Enter" && addFact()}
placeholder="Fact the assistant should remember in this project only" style={input} />
<button onClick={addFact} style={btn}>Add</button>
</div>
</div>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Facts learned in this project's chats land here. Global facts stay on the Memory page.
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Documents</h3>
<button onClick={() => { setMessage(""); setUploading(true); }} style={btn}> Add documents</button>
</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={card}>
<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={danger}>Delete</button>
</div>
))}
</div>
)}
</>
)}
{uploading && (
<Modal title="Add documents to this project" onClose={() => setUploading(false)}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or drop / pick a .pdf/.docx/.txt/.md file"
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={{ ...btn, padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
</Modal>
)}
{picking && (
<Modal title="Add an existing chat" onClose={() => setPicking(null)}>
{picking.length === 0 ? (
<div style={{ color: "#777" }}>No other chats.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{picking.map(c => (
<div key={c.id} style={card}>
<div style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<button onClick={() => addChat(c.id)} style={ghost}>Add</button>
</div>
))}
</div>
)}
</Modal>
)}
{viewing && (
<Modal title={viewing.title} onClose={() => setViewing(null)}>
{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>
))}
</Modal>
)}
</div>
);
}