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)
This commit is contained in:
janvanwan
2026-08-25 09:13:55 -05:00
parent fe5d18afa7
commit 42eaed647a
88 changed files with 4280 additions and 1888 deletions
+5 -2
View File
@@ -1,12 +1,12 @@
{
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
@@ -21,6 +21,9 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.4"
},
"engines": {
"node": ">=20.19"
}
},
"node_modules/@babel/code-frame": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"engines": {
"node": ">=20.19"
+181 -22
View File
@@ -4,8 +4,9 @@ import { Playbook } from "./Playbook";
import { Models } from "./Models";
import { Settings } from "./Settings";
import { Memory } from "./Memory";
import { Documents } from "./Documents";
import { Projects } from "./Projects";
import { Logs } from "./Logs";
import { MODULES } from "./modules/registry";
import { API_BASE } from "./config";
@@ -25,6 +26,10 @@ function App() {
const [ollamaStatus, setOllamaStatus] = useState("checking");
const [ollamaBusy, setOllamaBusy] = useState(false);
const [showStatusTooltip, setShowStatusTooltip] = useState(false);
const [showModulesMenu, setShowModulesMenu] = useState(false);
const [update, setUpdate] = useState(null);
const [updateBusy, setUpdateBusy] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID());
const [isModelPulling, setIsModelPulling] = useState(false);
@@ -47,6 +52,36 @@ function App() {
});
};
// Update check — git fetch on the backend, so it is manual (and once at startup),
// not polled with loadStatus.
const checkUpdate = async () => {
setUpdateBusy(true);
try {
const r = await fetch(`${API_BASE}/update/check`);
setUpdate(await r.json());
} catch {
setUpdate({ error: "Backend unreachable" });
}
setUpdateBusy(false);
};
// Install the update: the backend spawns `ncp upgrade` detached and then gets
// stopped by it, so this request is the last one this build answers. The
// overlay effect below waits for the new build to come back.
const applyUpdate = async () => {
if (!confirm(`Install v${update?.remote_version} (${update?.behind} commit(s))?\n\n`
+ "NexusOS will pull, rebuild and restart. This takes a few minutes and "
+ "the page reloads itself when the new build is up.")) return;
try {
const r = await fetch(`${API_BASE}/update/apply`, { method: "POST" });
const d = await r.json();
if (!d.started) { alert(`Update could not start: ${d.error || "unknown error"}`); return; }
setUpdating(true);
} catch {
alert("Update could not start: backend unreachable.");
}
};
// Manual AI control — Ollama does not auto-start with the app.
const toggleOllama = async () => {
const action = ollamaStatus === "running" ? "stop" : "start";
@@ -81,6 +116,26 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => { checkUpdate(); }, []);
// Reload only after the backend has actually gone away and come back - it
// stays up for a few seconds after /update/apply returns, so a naive
// "poll until online" would reload the OLD build immediately.
useEffect(() => {
if (!updating) return;
let wentDown = false;
const t = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/status`, { cache: "no-store" });
if (!r.ok) throw new Error("not ok");
if (wentDown) window.location.reload();
} catch {
wentDown = true;
}
}, 3000);
return () => clearInterval(t);
}, [updating]);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/conversations`)
@@ -174,13 +229,30 @@ function App() {
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", icon: "📄", label: "Documents" },
{ key: "projects", icon: "📁", label: "Projects" },
{ key: "modules", icon: "📦", label: "Modules", isModulesMenu: true },
{ key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", icon: "⚙️", label: "Settings" },
];
return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{updating && (
<div style={{
position: "fixed", inset: 0, zIndex: 5000, background: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.6rem", textAlign: "center", padding: "2rem",
}}>
<h2 style={{ color: "#007acc", margin: 0 }}>Updating NexusOS</h2>
<p style={{ color: "#aaa", margin: 0, maxWidth: "34rem" }}>
Pulling, rebuilding and restarting. This page reloads itself when the
new build is up a few minutes is normal.
</p>
<p style={{ color: "#666", fontSize: "0.75rem", margin: 0 }}>
Progress is logged to runtime/logs/update.log
</p>
</div>
)}
{/* Sidebar */}
<aside style={{
width: "400px",
@@ -204,7 +276,24 @@ function App() {
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && (
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
<span
onClick={checkUpdate}
title={updateBusy ? "Checking for updates..."
: update?.error ? `Update check failed: ${update.error}`
: update?.behind ? `${update.behind} commit(s) behind origin/main (${update.latest})`
: "Up to date - click to check for updates"}
style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em", cursor: "pointer" }}
>v{version}</span>
)}
{update?.behind > 0 && !updating && (
<span
onClick={applyUpdate}
title={`Update available: v${update.remote_version} (${update.behind} commit(s) behind).\nClick to install and restart.`}
style={{
fontSize: "0.6rem", color: "#ff9800", border: "1px solid #ff9800",
borderRadius: "8px", padding: "0.05rem 0.4rem", cursor: "pointer",
}}
>update available</span>
)}
{/* Status Indicator */}
@@ -261,28 +350,97 @@ function App() {
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = currentPage === item.key;
const isActive = item.isModulesMenu
? MODULES.some(m => m.key === currentPage)
: currentPage === item.key;
const buttonStyle = {
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
};
if (item.isModulesMenu) {
return (
<div
key={item.key}
style={{ position: "relative" }}
onMouseEnter={() => setShowModulesMenu(true)}
onMouseLeave={() => setShowModulesMenu(false)}
>
<button
onClick={() => setShowModulesMenu(v => !v)}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
</button>
{showModulesMenu && (
<div style={{
position: "absolute",
left: "100%",
top: 0,
marginLeft: "0.4rem",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "0.35rem",
minWidth: "150px",
zIndex: 1000,
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
}}>
{MODULES.length === 0 ? (
<div style={{ padding: "0.35rem 0.5rem", color: "#666", fontSize: "0.78rem" }}>No modules installed.</div>
) : MODULES.map(m => {
const moduleActive = currentPage === m.key;
return (
<div
key={m.key}
onClick={() => { setCurrentPage(m.key); setShowModulesMenu(false); }}
style={{
padding: "0.35rem 0.5rem",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: "0.45rem",
background: "transparent",
borderLeft: `2px solid ${moduleActive ? "#007acc" : "transparent"}`,
color: moduleActive ? "#4aa3e0" : "#bbb",
fontWeight: moduleActive ? 600 : 400,
}}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{m.icon}</span>
<span>{m.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
@@ -512,7 +670,8 @@ function App() {
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
{currentPage === "projects" && <Projects onOpenChat={selectConversation} onNewChat={startNewChat} />}
{MODULES.map(m => currentPage === m.key && <m.Component key={m.key} />)}
{currentPage === "logs" && <Logs />}
{currentPage === "settings" && <Settings />}
</main>
+92 -53
View File
@@ -7,13 +7,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [queue, setQueue] = useState([]); // messages typed while a reply was streaming, sent in order once it's free
const [modelList, setModelList] = useState([]);
const [selectedModel, setSelectedModel] = useState(""); // "" = auto
const [autoModel, setAutoModel] = useState(null);
const [think, setThink] = useState(false); // extended thinking, mirrors Settings
const [showPicker, setShowPicker] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const [lastStats, setLastStats] = useState(null);
const [memoryToast, setMemoryToast] = useState(null);
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
@@ -122,12 +122,15 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
if (!conversationId) return;
if (abortRef.current) abortRef.current.abort();
let cancelled = false;
setLastStats(null);
fetch(`${API_BASE}/conversations/${conversationId}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (cancelled) return;
setMessages(data?.messages?.map(m => ({ role: m.role, content: m.content })) || []);
// tokens/model are persisted per message; elapsed/rate are live-only.
setMessages(data?.messages?.map(m => ({
role: m.role, content: m.content, model: m.model,
stats: m.tokens ? { tokens: m.tokens } : undefined,
})) || []);
})
.catch(() => { if (!cancelled) setMessages([]); });
return () => { cancelled = true; };
@@ -136,7 +139,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
// Keyed on `visible`, not []: this component stays mounted while other pages
// show (App hides it with display:none so an in-flight reply survives
// navigation), so a mount-once fetch left the picker showing whatever was
// installed when the tab first opened - a model pulled on the Models page
// installed when the tab first opened a model pulled on the Models page
// didn't appear here until a full browser reload.
useEffect(() => {
if (!visible) return;
@@ -151,7 +154,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
setThink(!!settings.think);
}
}).catch(() => {});
}, [visible]);
}, [visible]);
useEffect(() => {
if (!showPicker) return;
@@ -199,8 +202,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
const startNewChat = () => {
if (abortRef.current) abortRef.current.abort();
setInput("");
setQueue([]);
setLoading(false);
setLastStats(null);
setConversationId(crypto.randomUUID());
};
@@ -275,15 +278,12 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
if (pendingEventType === "meta") {
try {
const stats = JSON.parse(payload);
setLastStats(stats);
// Tag this message with the model that answered
if (stats.model) {
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model };
return updated;
});
}
// Tag this message with the model that answered and its token stats
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = { ...updated[assistantIndex], model: stats.model, stats };
return updated;
});
} catch { /* ignore */ }
pendingEventType = null;
continue;
@@ -381,14 +381,8 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
messages.slice(0, index).filter(m => m.content.trim() !== "")
.map(m => ({ role: m.role, content: m.content }));
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
// Shared by an immediate send and an auto-flushed queued message.
const doSend = async (userMessage, outImages = []) => {
// Note any attached images so image-only turns aren't blank
const shownContent = outImages.length
? `${userMessage}${userMessage ? "\n\n" : ""}📷 ${outImages.length} image${outImages.length === 1 ? "" : "s"} attached`
@@ -399,6 +393,35 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
await streamAssistant({ message: userMessage, history, images: outImages, assistantIndex });
};
const sendMessage = async () => {
if ((!input.trim() && images.length === 0) || loading) return;
const userMessage = input.trim();
const outImages = images.map(i => i.b64);
setInput("");
setImages([]);
await doSend(userMessage, outImages);
};
// Enter while a reply is streaming queues the message instead of sending it;
// it's auto-sent, in order, once the current reply finishes (see the flush effect below).
const queueMessage = () => {
const text = input.trim();
if (!text) return;
setQueue(prev => [...prev, text]);
setInput("");
};
const removeQueued = (idx) => setQueue(prev => prev.filter((_, i) => i !== idx));
// Flush one queued message each time the box goes idle.
useEffect(() => {
if (loading || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
doSend(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, queue]);
const regenerate = async () => {
if (loading) return;
const lastUserIdx = messages.map(m => m.role).lastIndexOf("user");
@@ -445,25 +468,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
if (loading) queueMessage();
else sendMessage();
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", flexGrow: 1 }}>
{memoryToast && (
<div style={{
position: "fixed", bottom: "1.5rem", right: "1.5rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "8px", padding: "0.6rem 1rem",
fontSize: "0.85rem", color: "#c4b5fd",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)", zIndex: 9999,
maxWidth: "320px"
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]<br />
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{
flexGrow: 1,
background: "#161616",
@@ -576,19 +587,6 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
{think ? "on" : "off"}
</span>
</button>
{lastStats && (
<span style={{
fontSize: "0.7rem",
color: "#888",
background: "#1a1a1a",
border: "1px solid #2a2a2a",
borderRadius: "4px",
padding: "0.15rem 0.45rem",
}}>
{lastStats.tokens} tok · {lastStats.elapsed_s}s
{lastStats.tokens_per_s > 0 ? ` · ${lastStats.tokens_per_s} t/s` : ""}
</span>
)}
</div>
<button
onClick={startNewChat}
@@ -706,6 +704,14 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
Regenerate
</button>
)}
{msg.stats && (
<span style={{ padding: "0.15rem 0.5rem", fontSize: "0.7rem", color: "#555" }}>
{msg.stats.tokens} tok
{msg.stats.elapsed_s ? ` · ${msg.stats.elapsed_s}s` : ""}
{msg.stats.tokens_per_s > 0 ? ` · ${msg.stats.tokens_per_s} t/s` : ""}
{msg.model ? ` · ${msg.model}` : ""}
</span>
)}
{msg.role === "assistant" && ttsSupported && (
<button
onClick={() => speak(idx, msg.content)}
@@ -763,6 +769,28 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
))}
</div>
)}
{queue.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.3rem", marginBottom: "0.5rem" }}>
{queue.map((text, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.35rem 0.6rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "8px", color: "#999", fontSize: "0.8rem" }}>
<span style={{ color: "#557", flexShrink: 0 }}>Queued #{i + 1}</span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flexGrow: 1 }}>{text}</span>
<button onClick={() => removeQueued(i)}
style={{ background: "none", border: "none", color: "#ff8a80", cursor: "pointer", padding: 0, flexShrink: 0 }}></button>
</div>
))}
</div>
)}
{memoryToast && (
<div style={{
marginBottom: "0.5rem", padding: "0.5rem 0.9rem",
background: "#1a1a2e", border: "1px solid #7c3aed",
borderRadius: "10px", fontSize: "0.85rem", color: "#c4b5fd",
}}>
🧠 <strong>Memory saved</strong> [{memoryToast.section}]{" "}
<span style={{ color: "#aaa", fontSize: "0.8rem" }}>{memoryToast.text}</span>
</div>
)}
<div style={{ display: "flex", gap: "0.75rem", alignItems: "stretch" }}>
<label title="Attach image (needs a vision model)"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0 0.9rem", background: "#222", border: "1px solid #333", borderRadius: "10px", cursor: loading ? "default" : "pointer", opacity: loading ? 0.6 : 1, color: "#ccc" }}>
@@ -780,8 +808,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
disabled={loading}
placeholder={loading ? "Keep typing — Enter queues it for after this reply..." : "Type your message... (Shift+Enter for new line)"}
rows={3}
style={{
flexGrow: 1,
@@ -792,9 +819,21 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
borderRadius: "10px",
fontFamily: "system-ui",
resize: "none",
opacity: loading ? 0.6 : 1
}}
/>
{loading && input.trim() && (
<button
onClick={queueMessage}
title="Queue this message for after the current reply"
style={{
display: "flex", alignItems: "center", justifyContent: "center",
padding: "0 1.25rem", background: "#2a2a4a", color: "#fff",
border: "1px solid #444a7a", borderRadius: "8px", cursor: "pointer",
}}
>
Queue
</button>
)}
<button
onClick={loading ? stopGeneration : sendMessage}
disabled={!loading && !input.trim() && images.length === 0}
-229
View File
@@ -1,229 +0,0 @@
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 [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
} catch { /* offline */ }
};
// Switch the active workspace (persisted in settings; scopes chat RAG too).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
load();
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 documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
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(); loadProjects(); }, []);
// 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", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
<select
value={activeProject}
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
>
<option value="">All documents</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
))}
<option value="__new__"> New project…</option>
</select>
{activeProject && (
<button onClick={removeProject} title="Delete this project (documents kept)"
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
Delete project
</button>
)}
<span style={{ color: "#666", fontSize: "0.78rem" }}>
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
</span>
</div>
<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>
);
}
+20 -1
View File
@@ -44,6 +44,7 @@ export function Memory() {
const [message, setMessage] = useState("");
const [draggingId, setDraggingId] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
const [projectNames, setProjectNames] = useState({}); // id -> name, for the scope badge
const loadItems = useCallback(async () => {
try {
@@ -57,6 +58,15 @@ export function Memory() {
useEffect(() => { loadItems(); }, [loadItems]);
// Facts scoped to a project only reach that project's chats — label them so
// this page doesn't read as "everything here applies everywhere".
useEffect(() => {
fetch(`${API_BASE}/projects`)
.then(r => r.ok ? r.json() : { projects: [] })
.then(d => setProjectNames(Object.fromEntries((d.projects || []).map(p => [p.id, p.name]))))
.catch(() => {});
}, []);
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
const saveNew = async (section, text) => {
@@ -386,7 +396,16 @@ export function Memory() {
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
>
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>
{displayText}
{item.project_id && (
<span title="Only injected into this project's chats"
style={{ marginLeft: "0.5rem", padding: "0.05rem 0.4rem", background: "#1b2b3a",
border: "1px solid #2c4a63", borderRadius: "6px", color: "#8ab4ff", fontSize: "0.72rem" }}>
{projectNames[item.project_id] || "project"}
</span>
)}
</span>
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
onMouseEnter={e => e.currentTarget.style.opacity = 1}
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
+430
View File
@@ -0,0 +1,430 @@
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>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
import { MailAccounts } from "./MailAccounts";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const accountLabel = (a) => a.label || a.from_addr || a.username || "(unnamed)";
export function Mail() {
const [accounts, setAccounts] = useState(null); // null until loaded
const [expanded, setExpanded] = useState({}); // {accountId: bool}
const [foldersByAccount, setFoldersByAccount] = useState({}); // {accountId: [folders]}
const [selection, setSelection] = useState(null); // {accountId, folder}
const [messages, setMessages] = useState([]);
const [selected, setSelected] = useState(null); // full message
const [composing, setComposing] = useState(null); // {accountId,to,cc,subject,body}
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [showAccounts, setShowAccounts] = useState(false);
const loadFoldersFor = async (accountId) => {
const r = await fetch(`${API_BASE}/mail/folders?account_id=${encodeURIComponent(accountId)}`);
const folders = r.ok ? (await r.json()).folders || [] : [];
setFoldersByAccount(prev => ({ ...prev, [accountId]: folders }));
return folders;
};
const loadMessages = async (sel = selection) => {
if (!sel) return;
setBusy(true); setSelected(null);
try {
const r = await fetch(`${API_BASE}/mail/messages?account_id=${encodeURIComponent(sel.accountId)}&folder=${encodeURIComponent(sel.folder)}&limit=40`);
const data = r.ok ? await r.json() : null;
setMessages(data ? data.messages || [] : []);
if (!r.ok) setNote((await r.json().catch(() => ({}))).detail || "Failed to load messages.");
} finally { setBusy(false); }
};
const bootstrap = async () => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
const configured = list.filter(a => a.configured);
setExpanded(Object.fromEntries(list.map(a => [a.id, true])));
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { bootstrap(); }, []);
const onAccountsChanged = async (list) => {
setAccounts(list);
setExpanded(prev => ({ ...Object.fromEntries(list.map(a => [a.id, true])), ...prev }));
const configured = list.filter(a => a.configured);
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
// if the active selection's account vanished, fall back to the first configured one
if (selection && !configured.some(a => a.id === selection.accountId)) {
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
} else {
setSelection(null); setMessages([]); setSelected(null);
}
} else if (!selection && configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
const openMessage = async (m) => {
setBusy(true);
try {
const r = await fetch(`${API_BASE}/mail/message?account_id=${encodeURIComponent(selection.accountId)}&folder=${encodeURIComponent(selection.folder)}&uid=${encodeURIComponent(m.uid)}`);
if (r.ok) { setSelected(await r.json()); setMessages(prev => prev.map(x => x.uid === m.uid ? { ...x, seen: true } : x)); }
} finally { setBusy(false); }
};
const deleteMessage = async (m) => {
if (!window.confirm("Delete this message?")) return;
await fetch(`${API_BASE}/mail/delete`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: selection.accountId, folder: selection.folder, uid: m.uid }),
});
setSelected(null); loadMessages();
};
const sendCompose = async () => {
setBusy(true); setNote("Sending…");
try {
const r = await fetch(`${API_BASE}/mail/send`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(composing),
});
const d = await r.json().catch(() => ({}));
if (r.ok) { setNote(`Sent to ${(d.to || []).join(", ")}.`); setComposing(null); }
else setNote(d.detail || "Send failed.");
} finally { setBusy(false); }
};
const startCompose = () => setComposing({
accountId: (selection && selection.accountId) || (accounts || []).find(a => a.configured)?.id || "",
to: "", cc: "", subject: "", body: "",
});
const reply = (m) => setComposing({
accountId: selection.accountId,
to: m.from, cc: "", subject: /^re:/i.test(m.subject || "") ? m.subject : `Re: ${m.subject || ""}`,
body: `\n\n---\nOn ${m.date ? new Date(m.date).toLocaleString() : ""}, ${m.from} wrote:\n${(m.text || "").slice(0, 2000)}`,
});
const selectFolder = (accountId, folder) => {
const sel = { accountId, folder };
setSelection(sel);
loadMessages(sel);
};
const toggleExpanded = (accountId) => setExpanded(prev => ({ ...prev, [accountId]: !prev[accountId] }));
const configuredAccounts = (accounts || []).filter(a => a.configured);
if (accounts === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
// ---- empty state: no configured accounts yet ----
if (configuredAccounts.length === 0) {
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "560px" }}>
<h2 style={{ marginTop: 0 }}> Mail</h2>
<p style={{ color: "#aaa" }}>No mail accounts configured yet.</p>
<button onClick={() => setShowAccounts(true)} style={btn("#007acc")}>+ Add mail account</button>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
// ---- client ----
return (
<div style={{ display: "flex", height: "100%", color: "#eee" }}>
<div style={{ width: "200px", borderRight: "1px solid #2a2a2a", padding: "0.75rem", overflowY: "auto", flexShrink: 0, display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "0.4rem", marginBottom: "0.75rem" }}>
<button onClick={startCompose} style={{ ...btn("#007acc"), flexGrow: 1 }}> Compose</button>
<button onClick={() => setShowAccounts(true)} title="Manage accounts"
style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", padding: "0.5rem 0.6rem" }}></button>
</div>
<div style={{ flexGrow: 1, overflowY: "auto" }}>
{(accounts || []).map(a => (
<div key={a.id} style={{ marginBottom: "0.5rem" }}>
<div onClick={() => toggleExpanded(a.id)}
style={{ display: "flex", alignItems: "center", gap: "0.35rem", padding: "0.3rem 0.3rem", cursor: "pointer", color: a.configured ? "#ddd" : "#c9a227", fontSize: "0.82rem", fontWeight: 600 }}>
<span style={{ display: "inline-block", width: "0.8em", transform: expanded[a.id] ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}></span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{accountLabel(a)}</span>
</div>
{expanded[a.id] && (
!a.configured ? (
<div style={{ padding: "0.2rem 0.5rem 0.2rem 1.4rem", fontSize: "0.75rem", color: "#c9a227" }}>needs password to fix</div>
) : (foldersByAccount[a.id] || []).map(f => {
const isActive = selection && selection.accountId === a.id && selection.folder === f;
return (
<div key={f} onClick={() => selectFolder(a.id, f)}
style={{ padding: "0.32rem 0.5rem 0.32rem 1.4rem", borderRadius: "6px", cursor: "pointer", fontSize: "0.8rem", background: isActive ? "#1c2a3a" : "transparent", color: isActive ? "#fff" : "#aaa", marginBottom: "0.1rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{f}
</div>
);
})
)}
</div>
))}
</div>
</div>
<div style={{ width: "320px", borderRight: "1px solid #2a2a2a", overflowY: "auto", flexShrink: 0 }}>
<div style={{ padding: "0.5rem 0.75rem", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #2a2a2a" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selection ? selection.folder : "—"}
</span>
<button onClick={() => loadMessages()} disabled={!selection} style={{ background: "none", border: "none", color: "#888", cursor: "pointer" }}></button>
</div>
{!selection ? <div style={{ padding: "1rem", color: "#777" }}>Select a folder.</div>
: busy && messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>Loading</div>
: messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>{note || "No messages."}</div>
: messages.map(m => (
<div key={m.uid} onClick={() => openMessage(m)}
style={{ padding: "0.6rem 0.75rem", borderBottom: "1px solid #1e1e1e", cursor: "pointer", background: selected && selected.uid === m.uid ? "#1c2a3a" : "transparent" }}>
<div style={{ fontSize: "0.8rem", color: m.seen ? "#999" : "#fff", fontWeight: m.seen ? 400 : 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.from}</div>
<div style={{ fontSize: "0.82rem", color: m.seen ? "#aaa" : "#eee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.subject || "(no subject)"}</div>
<div style={{ fontSize: "0.72rem", color: "#666", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.preview}</div>
</div>
))}
</div>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "1rem", minWidth: 0 }}>
{composing ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "640px" }}>
<h3 style={{ margin: 0 }}>New message</h3>
{configuredAccounts.length > 1 && (
<div>
<label style={{ display: "block", fontSize: "0.75rem", color: "#888", marginBottom: "0.3rem" }}>From</label>
<select value={composing.accountId} onChange={e => setComposing({ ...composing, accountId: e.target.value })}
style={{ ...inputStyle, width: "100%" }}>
{configuredAccounts.map(a => <option key={a.id} value={a.id}>{accountLabel(a)} {a.from_addr || a.username}</option>)}
</select>
</div>
)}
<input placeholder="To" value={composing.to} onChange={e => setComposing({ ...composing, to: e.target.value })} style={inputStyle} />
<input placeholder="Cc" value={composing.cc} onChange={e => setComposing({ ...composing, cc: e.target.value })} style={inputStyle} />
<input placeholder="Subject" value={composing.subject} onChange={e => setComposing({ ...composing, subject: e.target.value })} style={inputStyle} />
<textarea rows={14} placeholder="Body" value={composing.body} onChange={e => setComposing({ ...composing, body: e.target.value })} style={inputStyle} />
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button onClick={sendCompose} disabled={busy || !composing.to.trim()} style={btn(busy || !composing.to.trim() ? "#555" : "#007acc")}>Send</button>
<button onClick={() => setComposing(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>Cancel</button>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
) : selected ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem" }}>{selected.subject || "(no subject)"}</h3>
<div style={{ display: "flex", gap: "0.4rem", flexShrink: 0 }}>
<button onClick={() => reply(selected)} style={btn("#2a4a6a")}>Reply</button>
<button onClick={() => deleteMessage(selected)} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>
</div>
</div>
<div style={{ color: "#aaa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>
<div><b>From:</b> {selected.from}</div>
<div><b>To:</b> {(selected.to || []).join(", ")}</div>
{selected.date && <div>{new Date(selected.date).toLocaleString()}</div>}
</div>
{/* Sandboxed iframe: no scripts, no same-origin — email HTML can't touch the app. */}
{selected.html
? <iframe title="message" sandbox="" srcDoc={selected.html} style={{ width: "100%", height: "60vh", border: "1px solid #2a2a2a", borderRadius: "8px", background: "#fff" }} />
: <pre style={{ whiteSpace: "pre-wrap", fontFamily: "system-ui", color: "#ddd" }}>{selected.text || "(empty)"}</pre>}
{selected.attachments && selected.attachments.length > 0 && (
<div style={{ marginTop: "0.75rem", color: "#888", fontSize: "0.8rem" }}>
📎 {selected.attachments.map(a => a.name).join(", ")}
</div>
)}
</div>
) : (
<div style={{ color: "#777", paddingTop: "2rem", textAlign: "center" }}>Select a message.</div>
)}
</div>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const labelStyle = { display: "block", fontSize: "0.72rem", color: "#888", marginBottom: "0.3rem", textTransform: "uppercase", letterSpacing: "0.05em" };
const BLANK = {
label: "", username: "", password: "", from_addr: "", from_name: "",
imap_host: "imap.mail.me.com", imap_port: 993, smtp_host: "smtp.mail.me.com", smtp_port: 587,
};
// macOS-Mail-style "Internet Accounts" window: accounts on the left, the
// selected account's config on the right. Lets you manage several mailboxes
// that happen to share the same IMAP/SMTP server.
export function MailAccounts({ onClose, onChanged }) {
const [accounts, setAccounts] = useState(null); // null = loading
const [selectedId, setSelectedId] = useState(null); // null = "add account" form
const [form, setForm] = useState(BLANK);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const selectAccount = (id, list = accounts) => {
setNote("");
setSelectedId(id);
if (id === null) { setForm(BLANK); return; }
const a = (list || []).find(x => x.id === id);
if (a) setForm({ ...a, password: "" });
};
const load = async (selectAfter) => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
onChanged && onChanged(list);
if (selectAfter !== undefined) selectAccount(selectAfter, list);
return list;
};
useEffect(() => {
load().then(list => { if (list.length) selectAccount(list[0].id, list); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async () => {
setBusy(true); setNote("Saving…");
try {
const url = selectedId ? `${API_BASE}/mail/accounts/${selectedId}` : `${API_BASE}/mail/accounts`;
const r = await fetch(url, {
method: selectedId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!r.ok) { setNote("Save failed."); return; }
const saved = await r.json();
setNote("Saved.");
await load(saved.id);
} finally { setBusy(false); }
};
const test = async () => {
if (!selectedId) { setNote("Save the account first."); return; }
setBusy(true); setNote("Testing…");
try {
const r = await fetch(`${API_BASE}/mail/accounts/${selectedId}/test`, { method: "POST" });
const d = await r.json().catch(() => ({}));
setNote(d.ok ? `Connected — ${d.folders} folders.` : `Connection failed: ${d.error || "check credentials"}`);
} finally { setBusy(false); }
};
const remove = async () => {
if (!selectedId) return;
if (!window.confirm(`Delete account "${form.label || form.from_addr || form.username}"?`)) return;
setBusy(true);
try {
await fetch(`${API_BASE}/mail/accounts/${selectedId}`, { method: "DELETE" });
const list = await load();
selectAccount(list.length ? list[0].id : null, list);
} finally { setBusy(false); }
};
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", width: "720px", maxWidth: "100%", height: "520px", maxHeight: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.85rem 1.1rem", borderBottom: "1px solid #2a2a2a", flexShrink: 0 }}>
<strong>Mail Accounts</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
{/* Left: accounts bar */}
<div style={{ width: "200px", flexShrink: 0, borderRight: "1px solid #2a2a2a", display: "flex", flexDirection: "column" }}>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "0.5rem" }}>
{accounts === null ? (
<div style={{ color: "#666", fontSize: "0.82rem", padding: "0.5rem" }}>Loading</div>
) : accounts.length === 0 ? (
<div style={{ color: "#666", fontSize: "0.8rem", padding: "0.5rem" }}>No accounts yet.</div>
) : accounts.map(a => (
<div key={a.id} onClick={() => selectAccount(a.id)}
style={{
padding: "0.5rem 0.6rem", borderRadius: "6px", cursor: "pointer", marginBottom: "0.2rem",
background: selectedId === a.id ? "#1c2a3a" : "transparent",
color: selectedId === a.id ? "#fff" : "#ccc",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.label || a.from_addr || a.username || "(unnamed)"}
</div>
<div style={{ fontSize: "0.72rem", color: a.configured ? "#8aff8a" : "#c9a227", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.configured ? (a.from_addr || a.username) : "needs password"}
</div>
</div>
))}
</div>
<div style={{ padding: "0.5rem", borderTop: "1px solid #2a2a2a" }}>
<button onClick={() => selectAccount(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", width: "100%" }}>+ Add account</button>
</div>
</div>
{/* Right: selected account form */}
<div style={{ flexGrow: 1, padding: "1.1rem", overflowY: "auto" }}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#eee" }}>
{selectedId ? "Edit account" : "New account"}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "440px" }}>
<div>
<label style={labelStyle}>Nickname (optional)</label>
<input value={form.label} onChange={e => update("label", e.target.value)} placeholder="e.g. Personal, Work" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Apple ID (login)</label>
<input value={form.username} onChange={e => update("username", e.target.value)} placeholder="you@icloud.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{selectedId ? "App-specific password (blank = keep current)" : "App-specific password"}</label>
<input type="password" value={form.password} onChange={e => update("password", e.target.value)} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From address</label>
<input value={form.from_addr} onChange={e => update("from_addr", e.target.value)} placeholder="nexus@enderofwings.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From name (optional)</label>
<input value={form.from_name} onChange={e => update("from_name", e.target.value)} style={inputStyle} />
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>IMAP host</label>
<input value={form.imap_host} onChange={e => update("imap_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.imap_port} onChange={e => update("imap_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>SMTP host</label>
<input value={form.smtp_host} onChange={e => update("smtp_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.smtp_port} onChange={e => update("smtp_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginTop: "0.4rem" }}>
<button onClick={save} disabled={busy} style={btn(busy ? "#555" : "#007acc")}>{selectedId ? "Save" : "Create"}</button>
{selectedId && <button onClick={test} disabled={busy} style={btn("#2a4a6a")}>Test connection</button>}
{selectedId && <button onClick={remove} disabled={busy} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>}
</div>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Mail as Component } from "./Mail";
export const MANIFEST = { key: "mail", label: "Mail", icon: "✉️", order: 1 };
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const sectionStyle = { background: "#161616", border: "1px solid #333", borderRadius: "12px", padding: "1.25rem", marginBottom: "1rem" };
const dot = (ok) => ({
display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0,
background: ok === null ? "#555" : ok ? "#4caf50" : "#f44336",
});
export function Network() {
const [status, setStatus] = useState(null); // {hostname, connection, vpn}
const [targets, setTargets] = useState(null); // [{id,label,host,ok,latency_ms}]
const [newLabel, setNewLabel] = useState("");
const [newHost, setNewHost] = useState("");
const [busy, setBusy] = useState(false);
const [vpnBusy, setVpnBusy] = useState(false);
const [note, setNote] = useState("");
const loadStatus = async () => {
const r = await fetch(`${API_BASE}/network/status`);
if (r.ok) setStatus(await r.json());
};
const loadAndPingTargets = async () => {
const r = await fetch(`${API_BASE}/network/ping`);
setTargets(r.ok ? (await r.json()).targets || [] : []);
};
const refresh = async () => {
setBusy(true);
try { await Promise.all([loadStatus(), loadAndPingTargets()]); }
finally { setBusy(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { refresh(); }, []);
const toggleVpn = async () => {
if (!status || !status.vpn.configured) return;
setVpnBusy(true); setNote("");
try {
const r = await fetch(`${API_BASE}/network/vpn/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable: !status.vpn.connected }),
});
const d = await r.json().catch(() => ({}));
if (r.ok) setStatus(s => ({ ...s, vpn: d }));
else setNote(d.detail || "VPN toggle failed.");
} finally { setVpnBusy(false); }
};
const addTarget = async () => {
if (!newHost.trim()) return;
setBusy(true);
try {
const r = await fetch(`${API_BASE}/network/targets`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLabel.trim(), host: newHost.trim() }),
});
if (r.ok) { setNewLabel(""); setNewHost(""); await loadAndPingTargets(); }
} finally { setBusy(false); }
};
const removeTarget = async (id) => {
setBusy(true);
try {
await fetch(`${API_BASE}/network/targets/${id}`, { method: "DELETE" });
await loadAndPingTargets();
} finally { setBusy(false); }
};
if (status === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
const conn = status.connection;
const vpn = status.vpn;
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", color: "#eee" }}>📡 Network</h2>
<button onClick={refresh} disabled={busy} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>
{busy ? "Refreshing…" : "⟳ Refresh"}
</button>
</div>
{/* Connection */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>Connection</h3>
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", fontSize: "0.88rem" }}>
<div><span style={{ color: "#666" }}>Host</span><div style={{ color: "#eee" }}>{status.hostname}</div></div>
<div><span style={{ color: "#666" }}>Type</span><div style={{ color: "#eee", textTransform: "capitalize" }}>{conn.type}</div></div>
<div><span style={{ color: "#666" }}>Interface</span><div style={{ color: "#eee" }}>{conn.interface || "—"}</div></div>
<div><span style={{ color: "#666" }}>IP</span><div style={{ color: "#eee" }}>{conn.ip || "—"}</div></div>
</div>
</div>
{/* VPN */}
{vpn.available && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>WireGuard VPN</h3>
{!vpn.configured ? (
<p style={{ margin: 0, fontSize: "0.85rem", color: "#666" }}>No WireGuard tunnel configured in NetworkManager.</p>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={dot(vpn.connected)} />
<span style={{ fontSize: "0.88rem", color: "#ccc" }}>{vpn.name}</span>
<span style={{ fontSize: "0.82rem", color: vpn.connected ? "#4caf50" : "#888" }}>
{vpn.connected ? "Connected" : "Disconnected"}
</span>
<button onClick={toggleVpn} disabled={vpnBusy}
style={{ ...btn(vpn.connected ? "#2a1a1a" : "#152a15"), color: vpn.connected ? "#ff8a80" : "#8aff8a", marginLeft: "auto" }}>
{vpnBusy ? "Working…" : vpn.connected ? "Disconnect" : "Connect"}
</button>
</div>
)}
{note && <p style={{ margin: "0.5rem 0 0", color: "#f44336", fontSize: "0.82rem" }}>{note}</p>}
</div>
)}
{/* Ping targets */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Ping targets</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
Hosts to check reachability for a router, a VPN endpoint, anything on your network.
</p>
{(targets || []).length === 0 ? (
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "1rem" }}>No targets yet.</div>
) : (
<div style={{ marginBottom: "1rem" }}>
{targets.map(t => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.45rem 0", borderBottom: "1px solid #222" }}>
<span style={dot(t.ok)} />
<span style={{ fontSize: "0.88rem", color: "#eee", minWidth: "120px" }}>{t.label}</span>
<span style={{ fontSize: "0.82rem", color: "#888" }}>{t.host}</span>
<span style={{ fontSize: "0.8rem", color: "#666", marginLeft: "auto" }}>
{t.ok ? (t.latency_ms != null ? `${t.latency_ms.toFixed(0)} ms` : "reachable") : t.ok === false ? "unreachable" : ""}
</span>
<button onClick={() => removeTarget(t.id)} disabled={busy}
style={{ background: "transparent", border: "none", color: "#888", cursor: "pointer", fontSize: "0.9rem", padding: "0 4px" }}></button>
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input placeholder="Label (optional)" value={newLabel} onChange={e => setNewLabel(e.target.value)}
style={{ ...inputStyle, width: "160px" }} />
<input placeholder="Host or IP" value={newHost} onChange={e => setNewHost(e.target.value)}
onKeyDown={e => e.key === "Enter" && addTarget()} style={{ ...inputStyle, width: "200px" }} />
<button onClick={addTarget} disabled={busy || !newHost.trim()} style={btn(!newHost.trim() ? "#555" : "#007acc")}>+ Add</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Network as Component } from "./Network";
export const MANIFEST = { key: "network", label: "Network", icon: "📡", order: 2 };
+10
View File
@@ -0,0 +1,10 @@
// Auto-discovers feature modules under modules/ — any folder with a
// module.jsx exporting MANIFEST ({key,label,icon,order}) + Component is
// picked up automatically. Add a new module folder and it appears in the
// Modules hover menu with no edits here.
const discovered = import.meta.glob("./*/module.jsx", { eager: true });
export const MODULES = Object.values(discovered)
.filter(m => m.MANIFEST && m.Component)
.map(m => ({ ...m.MANIFEST, Component: m.Component }))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));