forked from enderofwings/NexusOS
Merge origin/main (v1.2.0: Projects, modules, in-app updates)
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
This commit is contained in:
Generated
+2
-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": {
|
||||
"preact": "^10.29.8",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,13 +8,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
|
||||
@@ -123,12 +123,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; };
|
||||
@@ -137,7 +140,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;
|
||||
@@ -152,7 +155,7 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
|
||||
setThink(!!settings.think);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [visible]);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPicker) return;
|
||||
@@ -200,8 +203,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());
|
||||
};
|
||||
|
||||
@@ -276,15 +279,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;
|
||||
@@ -382,14 +382,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`
|
||||
@@ -400,6 +394,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");
|
||||
@@ -446,25 +469,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",
|
||||
@@ -577,19 +588,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}
|
||||
@@ -707,6 +705,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)}
|
||||
@@ -767,6 +773,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" }}>
|
||||
@@ -784,8 +812,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,
|
||||
@@ -796,9 +823,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