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:
Generated
+5
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
|
||||
+181
-22
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user