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