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:
2026-08-26 02:09:23 -05:00
91 changed files with 4293 additions and 1896 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"preact": "^10.29.8",
"react": "^19.2.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"version": "1.2.0",
"type": "module",
"engines": {
"node": ">=20.19"
+181 -22
View File
@@ -4,8 +4,9 @@ import { Playbook } from "./Playbook";
import { Models } from "./Models";
import { Settings } from "./Settings";
import { Memory } from "./Memory";
import { Documents } from "./Documents";
import { Projects } from "./Projects";
import { Logs } from "./Logs";
import { MODULES } from "./modules/registry";
import { API_BASE } from "./config";
@@ -25,6 +26,10 @@ function App() {
const [ollamaStatus, setOllamaStatus] = useState("checking");
const [ollamaBusy, setOllamaBusy] = useState(false);
const [showStatusTooltip, setShowStatusTooltip] = useState(false);
const [showModulesMenu, setShowModulesMenu] = useState(false);
const [update, setUpdate] = useState(null);
const [updateBusy, setUpdateBusy] = useState(false);
const [updating, setUpdating] = useState(false);
const [activeConversationId, setActiveConversationId] = useState(() => crypto.randomUUID());
const [isModelPulling, setIsModelPulling] = useState(false);
@@ -47,6 +52,36 @@ function App() {
});
};
// Update check — git fetch on the backend, so it is manual (and once at startup),
// not polled with loadStatus.
const checkUpdate = async () => {
setUpdateBusy(true);
try {
const r = await fetch(`${API_BASE}/update/check`);
setUpdate(await r.json());
} catch {
setUpdate({ error: "Backend unreachable" });
}
setUpdateBusy(false);
};
// Install the update: the backend spawns `ncp upgrade` detached and then gets
// stopped by it, so this request is the last one this build answers. The
// overlay effect below waits for the new build to come back.
const applyUpdate = async () => {
if (!confirm(`Install v${update?.remote_version} (${update?.behind} commit(s))?\n\n`
+ "NexusOS will pull, rebuild and restart. This takes a few minutes and "
+ "the page reloads itself when the new build is up.")) return;
try {
const r = await fetch(`${API_BASE}/update/apply`, { method: "POST" });
const d = await r.json();
if (!d.started) { alert(`Update could not start: ${d.error || "unknown error"}`); return; }
setUpdating(true);
} catch {
alert("Update could not start: backend unreachable.");
}
};
// Manual AI control — Ollama does not auto-start with the app.
const toggleOllama = async () => {
const action = ollamaStatus === "running" ? "stop" : "start";
@@ -81,6 +116,26 @@ function App() {
return () => clearInterval(interval);
}, []);
useEffect(() => { checkUpdate(); }, []);
// Reload only after the backend has actually gone away and come back - it
// stays up for a few seconds after /update/apply returns, so a naive
// "poll until online" would reload the OLD build immediately.
useEffect(() => {
if (!updating) return;
let wentDown = false;
const t = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/status`, { cache: "no-store" });
if (!r.ok) throw new Error("not ok");
if (wentDown) window.location.reload();
} catch {
wentDown = true;
}
}, 3000);
return () => clearInterval(t);
}, [updating]);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/conversations`)
@@ -174,13 +229,30 @@ function App() {
{ key: "playbook", icon: "📖", label: "Playbooks" },
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
{ key: "memory", icon: "🧠", label: "Memory" },
{ key: "documents", icon: "📄", label: "Documents" },
{ key: "projects", icon: "📁", label: "Projects" },
{ key: "modules", icon: "📦", label: "Modules", isModulesMenu: true },
{ key: "logs", icon: "📜", label: "Logs" },
{ key: "settings", icon: "⚙️", label: "Settings" },
];
return (
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
{updating && (
<div style={{
position: "fixed", inset: 0, zIndex: 5000, background: "rgba(0,0,0,0.88)",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.6rem", textAlign: "center", padding: "2rem",
}}>
<h2 style={{ color: "#007acc", margin: 0 }}>Updating NexusOS</h2>
<p style={{ color: "#aaa", margin: 0, maxWidth: "34rem" }}>
Pulling, rebuilding and restarting. This page reloads itself when the
new build is up a few minutes is normal.
</p>
<p style={{ color: "#666", fontSize: "0.75rem", margin: 0 }}>
Progress is logged to runtime/logs/update.log
</p>
</div>
)}
{/* Sidebar */}
<aside style={{
width: "400px",
@@ -204,7 +276,24 @@ function App() {
<img src="/n small.png" alt="Logo" style={{ width: "36px", height: "36px", objectFit: "contain", borderRadius: "6px" }} />
<h1 style={{ fontSize: "0.9rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
{version && (
<span style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em" }}>v{version}</span>
<span
onClick={checkUpdate}
title={updateBusy ? "Checking for updates..."
: update?.error ? `Update check failed: ${update.error}`
: update?.behind ? `${update.behind} commit(s) behind origin/main (${update.latest})`
: "Up to date - click to check for updates"}
style={{ fontSize: "0.65rem", color: "#666", marginTop: "-0.35rem", letterSpacing: "0.02em", cursor: "pointer" }}
>v{version}</span>
)}
{update?.behind > 0 && !updating && (
<span
onClick={applyUpdate}
title={`Update available: v${update.remote_version} (${update.behind} commit(s) behind).\nClick to install and restart.`}
style={{
fontSize: "0.6rem", color: "#ff9800", border: "1px solid #ff9800",
borderRadius: "8px", padding: "0.05rem 0.4rem", cursor: "pointer",
}}
>update available</span>
)}
{/* Status Indicator */}
@@ -261,28 +350,97 @@ function App() {
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
{navItems.map(item => {
const isActive = currentPage === item.key;
const isActive = item.isModulesMenu
? MODULES.some(m => m.key === currentPage)
: currentPage === item.key;
const buttonStyle = {
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
};
if (item.isModulesMenu) {
return (
<div
key={item.key}
style={{ position: "relative" }}
onMouseEnter={() => setShowModulesMenu(true)}
onMouseLeave={() => setShowModulesMenu(false)}
>
<button
onClick={() => setShowModulesMenu(v => !v)}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{item.icon}</span>
<span style={{ flexGrow: 1 }}>{item.label}</span>
</button>
{showModulesMenu && (
<div style={{
position: "absolute",
left: "100%",
top: 0,
marginLeft: "0.4rem",
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "0.35rem",
minWidth: "150px",
zIndex: 1000,
boxShadow: "0 4px 12px rgba(0,0,0,0.5)",
}}>
{MODULES.length === 0 ? (
<div style={{ padding: "0.35rem 0.5rem", color: "#666", fontSize: "0.78rem" }}>No modules installed.</div>
) : MODULES.map(m => {
const moduleActive = currentPage === m.key;
return (
<div
key={m.key}
onClick={() => { setCurrentPage(m.key); setShowModulesMenu(false); }}
style={{
padding: "0.35rem 0.5rem",
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
whiteSpace: "nowrap",
display: "flex",
alignItems: "center",
gap: "0.45rem",
background: "transparent",
borderLeft: `2px solid ${moduleActive ? "#007acc" : "transparent"}`,
color: moduleActive ? "#4aa3e0" : "#bbb",
fontWeight: moduleActive ? 600 : 400,
}}
>
<span style={{ fontSize: "0.85rem", width: "1rem", textAlign: "center", flexShrink: 0 }}>{m.icon}</span>
<span>{m.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
return (
<button
key={item.key}
onClick={() => setCurrentPage(item.key)}
style={{
padding: "0.32rem 0.5rem",
background: "transparent",
color: isActive ? "#4aa3e0" : "#bbb",
fontWeight: isActive ? 600 : 400,
border: "none",
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
borderRadius: "5px",
cursor: "pointer",
fontSize: "0.8rem",
textAlign: "left",
transition: "background 0.12s, color 0.12s",
display: "flex",
alignItems: "center",
gap: "0.45rem",
width: "100%",
}}
style={buttonStyle}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "#161616"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "transparent"; }}
>
@@ -512,7 +670,8 @@ function App() {
</div>
{currentPage === "playbook" && <Playbook />}
{currentPage === "memory" && <Memory />}
{currentPage === "documents" && <Documents />}
{currentPage === "projects" && <Projects onOpenChat={selectConversation} onNewChat={startNewChat} />}
{MODULES.map(m => currentPage === m.key && <m.Component key={m.key} />)}
{currentPage === "logs" && <Logs />}
{currentPage === "settings" && <Settings />}
</main>
+92 -53
View File
@@ -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}
-229
View File
@@ -1,229 +0,0 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// RAG document manager: upload/paste text, chunked + embedded server-side, then
// retrieved into the chat system prompt. See synapse/memory/store.py.
export function Documents() {
const [docs, setDocs] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} being previewed
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (r.ok) { const d = await r.json(); setProjects(d.projects || []); setActiveProject(d.active || ""); }
} catch { /* offline */ }
};
// Switch the active workspace (persisted in settings; scopes chat RAG too).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
load();
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
const load = async () => {
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
} catch { /* offline — leave list as-is */ }
};
useEffect(() => { load(); loadProjects(); }, []);
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON — no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
reader.readAsDataURL(file);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px" }}>
<h2 style={{ marginTop: 0 }}>📄 Documents</h2>
<p style={{ color: "#aaa", marginTop: 0 }}>
Upload or paste text. It's chunked, embedded, and pulled into chat as source
material when a message is relevant.
</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.2rem", flexWrap: "wrap" }}>
<span style={{ color: "#888", fontSize: "0.85rem" }}>Workspace:</span>
<select
value={activeProject}
onChange={e => (e.target.value === "__new__" ? newProject() : setActive(e.target.value))}
style={{ padding: "0.5rem 0.7rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px" }}
>
<option value="">All documents</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name} ({p.docs})</option>
))}
<option value="__new__"> New project…</option>
</select>
{activeProject && (
<button onClick={removeProject} title="Delete this project (documents kept)"
style={{ padding: "0.4rem 0.7rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" }}>
Delete project
</button>
)}
<span style={{ color: "#666", fontSize: "0.78rem" }}>
{activeProject ? "Chat scopes to this project's documents." : "Chat searches all documents."}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem", marginBottom: "1.5rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or upload a .pdf/.docx/.txt/.md file below"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px" }}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)}
style={{ padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Delete
</button>
</div>
))}
</div>
)}
{viewing && (
<div onClick={() => setViewing(null)}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{viewing.title}</strong>
<button onClick={() => setViewing(null)}
style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}>✕</button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
+20 -1
View File
@@ -44,6 +44,7 @@ export function Memory() {
const [message, setMessage] = useState("");
const [draggingId, setDraggingId] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
const [projectNames, setProjectNames] = useState({}); // id -> name, for the scope badge
const loadItems = useCallback(async () => {
try {
@@ -57,6 +58,15 @@ export function Memory() {
useEffect(() => { loadItems(); }, [loadItems]);
// Facts scoped to a project only reach that project's chats — label them so
// this page doesn't read as "everything here applies everywhere".
useEffect(() => {
fetch(`${API_BASE}/projects`)
.then(r => r.ok ? r.json() : { projects: [] })
.then(d => setProjectNames(Object.fromEntries((d.projects || []).map(p => [p.id, p.name]))))
.catch(() => {});
}, []);
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
const saveNew = async (section, text) => {
@@ -386,7 +396,16 @@ export function Memory() {
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
>
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>
{displayText}
{item.project_id && (
<span title="Only injected into this project's chats"
style={{ marginLeft: "0.5rem", padding: "0.05rem 0.4rem", background: "#1b2b3a",
border: "1px solid #2c4a63", borderRadius: "6px", color: "#8ab4ff", fontSize: "0.72rem" }}>
{projectNames[item.project_id] || "project"}
</span>
)}
</span>
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
onMouseEnter={e => e.currentTarget.style.opacity = 1}
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
+430
View File
@@ -0,0 +1,430 @@
import { useEffect, useState } from "react";
import { API_BASE } from "./config";
// Project workspace: a project groups chats and RAG documents. The active
// project is persisted in settings, and chat scopes its document retrieval to
// it (see synapse/main.py chat_stream_endpoint). "" = unscoped / All.
const input = { padding: "0.9rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "10px", width: "100%", boxSizing: "border-box" };
const card = { padding: "0.8rem 1rem", background: "#1a1a1a", border: "1px solid #2a2a2a", borderRadius: "10px", display: "flex", justifyContent: "space-between", alignItems: "center", gap: "0.6rem" };
const btn = { padding: "0.55rem 1rem", background: "#007acc", color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" };
const ghost = { padding: "0.55rem 1rem", background: "#1a1a1a", color: "#ccc", border: "1px solid #333", borderRadius: "8px", cursor: "pointer" };
const danger = { padding: "0.5rem 0.9rem", background: "#2a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer", fontSize: "0.8rem" };
function Modal({ title, onClose, children }) {
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", maxWidth: "700px", width: "100%", maxHeight: "80vh", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "1rem 1.25rem", borderBottom: "1px solid #2a2a2a" }}>
<strong>{title}</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ overflowY: "auto", padding: "1rem 1.25rem" }}>{children}</div>
</div>
</div>
);
}
export function Projects({ onOpenChat, onNewChat }) {
const [docs, setDocs] = useState([]);
const [chats, setChats] = useState([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [viewing, setViewing] = useState(null); // {title, chunks} preview
const [uploading, setUploading] = useState(false); // upload modal open
const [picking, setPicking] = useState(null); // conversations available to add
const [projects, setProjects] = useState([]);
const [activeProject, setActiveProject] = useState(""); // "" = All
const [instructions, setInstructions] = useState(""); // per-project system prompt
const [savedInstructions, setSavedInstructions] = useState("");
const [facts, setFacts] = useState([]); // memory scoped to this project
const [newFact, setNewFact] = useState("");
const [dragging, setDragging] = useState(false); // file hovering the drop zone
const loadProjects = async () => {
try {
const r = await fetch(`${API_BASE}/projects`);
if (!r.ok) return;
const d = await r.json();
setProjects(d.projects || []);
setActiveProject(d.active || "");
const mine = (d.projects || []).find(p => p.id === (d.active || ""));
setInstructions(mine?.instructions || "");
setSavedInstructions(mine?.instructions || "");
} catch { /* offline */ }
};
const load = async (pid) => {
const scope = pid ?? activeProject;
if (!scope) { setDocs([]); setChats([]); setFacts([]); return; }
try {
const r = await fetch(`${API_BASE}/documents`);
if (r.ok) setDocs((await r.json()).documents || []);
const c = await fetch(`${API_BASE}/conversations?project=${encodeURIComponent(scope)}`);
if (c.ok) setChats((await c.json()).conversations || []);
const m = await fetch(`${API_BASE}/memory?project=${encodeURIComponent(scope)}`);
if (m.ok) setFacts((await m.json()).items || []);
} catch { /* offline — leave lists as-is */ }
};
useEffect(() => { loadProjects().then(() => {}); }, []);
useEffect(() => { load(activeProject); }, [activeProject]); // eslint-disable-line react-hooks/exhaustive-deps
// Switch the active workspace (persisted in settings; scopes chat RAG and
// any new conversation started from here).
const setActive = async (pid) => {
await fetch(`${API_BASE}/settings`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_project: pid }),
});
setActiveProject(pid);
loadProjects();
};
const newProject = async () => {
const name = window.prompt("New project name:");
if (!name || !name.trim()) return;
const r = await fetch(`${API_BASE}/projects`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim() }),
});
if (r.ok) { const p = await r.json(); await setActive(p.id); }
};
const removeProject = async () => {
if (!activeProject) return;
if (!window.confirm("Delete this project? Its chats and documents are kept but become unscoped.")) return;
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, { method: "DELETE" });
await setActive("");
};
// --- instructions --------------------------------------------------------
const saveInstructions = async () => {
await fetch(`${API_BASE}/projects/${encodeURIComponent(activeProject)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instructions }),
});
setSavedInstructions(instructions);
loadProjects();
};
// --- memory facts scoped to this project ---------------------------------
const addFact = async () => {
const text = newFact.trim();
if (!text) return;
await fetch(`${API_BASE}/memory`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, section: "Projects", project_id: activeProject }),
});
setNewFact("");
load();
};
const removeFact = async (id) => {
await fetch(`${API_BASE}/memory/${encodeURIComponent(id)}`, { method: "DELETE" });
load();
};
// --- chats ---------------------------------------------------------------
const moveChat = async (id, pid) => {
await fetch(`${API_BASE}/conversations/${encodeURIComponent(id)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: pid }),
});
load();
};
const openPicker = async () => {
const r = await fetch(`${API_BASE}/conversations`);
if (r.ok) setPicking(((await r.json()).conversations || []).filter(c => c.project_id !== activeProject));
};
const addChat = async (id) => {
await moveChat(id, activeProject);
setPicking(p => (p || []).filter(c => c.id !== id));
};
// --- documents -----------------------------------------------------------
const openDoc = async (doc) => {
try {
const r = await fetch(`${API_BASE}/documents/${encodeURIComponent(doc.doc_id)}`);
if (r.ok) setViewing({ title: doc.title, chunks: (await r.json()).chunks || [] });
} catch { /* ignore */ }
};
// Files (pdf/docx/txt/md) upload straight to the server, which extracts the
// text. Base64 in JSON — no multipart dependency.
const onFile = (e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (file) uploadFile(file);
};
// Resolves when the file is indexed, so a multi-file drop uploads in order
// instead of racing (each one re-renders the list on completion).
const uploadFile = (file) => new Promise((resolve) => {
setBusy(true);
setMessage(`Reading ${file.name}`);
const reader = new FileReader();
reader.onload = async () => {
const b64 = String(reader.result || "").split(",")[1];
try {
const r = await fetch(`${API_BASE}/documents/upload`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, data: b64 }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index file.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
resolve();
}
};
reader.readAsDataURL(file);
});
const onDrop = async (e) => {
e.preventDefault();
setDragging(false);
if (!activeProject) return;
const files = [...(e.dataTransfer?.files || [])];
if (!files.length) return;
setUploading(true);
for (const f of files) await uploadFile(f);
};
const addDoc = async () => {
if (!title.trim() || !content.trim()) {
setMessage("Title and content are required.");
return;
}
setBusy(true);
setMessage("Chunking and embedding…");
try {
const r = await fetch(`${API_BASE}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), content }),
});
if (r.ok) {
const d = await r.json();
setMessage(`Indexed "${d.title}" (${d.chunks} chunk${d.chunks === 1 ? "" : "s"}).`);
setTitle("");
setContent("");
load();
} else {
setMessage((await r.json().catch(() => ({}))).detail || "Failed to index.");
}
} catch (err) {
setMessage(`Error: ${err.message}`);
} finally {
setBusy(false);
}
};
const removeDoc = async (docId) => {
try {
await fetch(`${API_BASE}/documents/${encodeURIComponent(docId)}`, { method: "DELETE" });
load();
} catch { /* ignore */ }
};
const label = (c) => c.title || c.preview || "Untitled chat";
return (
<div
onDragOver={e => { e.preventDefault(); if (activeProject) setDragging(true); }}
onDragLeave={e => { if (e.currentTarget === e.target) setDragging(false); }}
onDrop={onDrop}
style={{ padding: "1.5rem", color: "#eee", maxWidth: "820px", position: "relative",
outline: dragging ? "2px dashed #007acc" : "none", borderRadius: "12px" }}
>
{!activeProject ? (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, flex: 1 }}>📁 Projects</h2>
<button onClick={newProject} style={btn}> New project</button>
</div>
{projects.length === 0 ? (
<div style={{ color: "#777" }}>
No projects. A project keeps its own chats, documents, memory and instructions.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{projects.map(p => (
<div key={p.id} onClick={() => setActive(p.id)} style={{ ...card, cursor: "pointer" }} title="Open project">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>
{p.chats} chat{p.chats === 1 ? "" : "s"} · {p.docs} document{p.docs === 1 ? "" : "s"}
</div>
</div>
<span style={{ color: "#555" }}></span>
</div>
))}
</div>
)}
</>
) : (
<>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "1.4rem" }}>
<button onClick={() => setActive("")} title="Back to all projects" style={ghost}> Projects</button>
<h2 style={{ margin: 0, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{projects.find(p => p.id === activeProject)?.name || "Project"}
</h2>
<button onClick={removeProject} title="Delete this project (chats and documents kept)" style={danger}>
Delete project
</button>
</div>
{dragging && (
<div style={{ position: "sticky", top: 0, zIndex: 5, background: "#0d2233", border: "1px solid #007acc",
borderRadius: "10px", padding: "0.7rem 1rem", marginBottom: "0.8rem", color: "#8ab4ff" }}>
Drop files to index them in this project.
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>How the assistant should act here</h3>
<textarea rows={4} value={instructions} onChange={e => setInstructions(e.target.value)}
placeholder="e.g. Answer as a build engineer. Prefer bash over Python. Always cite the doc you used."
style={input} />
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginTop: "0.5rem" }}>
<button onClick={saveInstructions} disabled={instructions === savedInstructions}
style={{ ...btn, background: instructions === savedInstructions ? "#333" : "#007acc",
cursor: instructions === savedInstructions ? "default" : "pointer" }}>
Save instructions
</button>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Layered under the active playbook, for chats in this project only.
</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Chats</h3>
<button onClick={onNewChat} style={btn}> New chat</button>
<button onClick={openPicker} style={ghost}>Add existing</button>
</div>
{chats.length === 0 ? (
<div style={{ color: "#777", marginBottom: "1.5rem" }}>No chats in this project yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", marginBottom: "1.5rem" }}>
{chats.map(c => (
<div key={c.id} style={card}>
<div onClick={() => onOpenChat(c.id)} style={{ cursor: "pointer", flex: 1, minWidth: 0 }} title="Open in chat">
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{new Date(c.updated_at * 1000).toLocaleString()}</div>
</div>
<button onClick={() => moveChat(c.id, "")} title="Remove from project (chat kept)" style={danger}>Remove</button>
</div>
))}
</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "1rem" }}>Project memory</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{facts.map(f => (
<div key={f.id} style={card}>
<span style={{ flex: 1, minWidth: 0, color: "#ddd", fontSize: "0.9rem" }}>{f.text}</span>
<button onClick={() => removeFact(f.id)} style={danger}>Delete</button>
</div>
))}
<div style={{ display: "flex", gap: "0.6rem" }}>
<input type="text" value={newFact} onChange={e => setNewFact(e.target.value)}
onKeyDown={e => e.key === "Enter" && addFact()}
placeholder="Fact the assistant should remember in this project only" style={input} />
<button onClick={addFact} style={btn}>Add</button>
</div>
</div>
<span style={{ color: "#666", fontSize: "0.78rem" }}>
Facts learned in this project's chats land here. Global facts stay on the Memory page.
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", marginBottom: "0.7rem" }}>
<h3 style={{ margin: 0, flex: 1, fontSize: "1rem" }}>Documents</h3>
<button onClick={() => { setMessage(""); setUploading(true); }} style={btn}> Add documents</button>
</div>
{docs.length === 0 ? (
<div style={{ color: "#777" }}>No documents yet.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{docs.map(d => (
<div key={d.doc_id} style={card}>
<div onClick={() => openDoc(d)} style={{ cursor: "pointer", flex: 1 }} title="View chunks">
<div style={{ fontWeight: 600 }}>{d.title}</div>
<div style={{ color: "#888", fontSize: "0.85rem" }}>{d.chunks} chunk{d.chunks === 1 ? "" : "s"}</div>
</div>
<button onClick={() => removeDoc(d.doc_id)} style={danger}>Delete</button>
</div>
))}
</div>
)}
</>
)}
{uploading && (
<Modal title="Add documents to this project" onClose={() => setUploading(false)}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.7rem" }}>
<input type="text" placeholder="Title" value={title}
onChange={e => setTitle(e.target.value)} style={input} />
<textarea rows={8} placeholder="Paste text here, or drop / pick a .pdf/.docx/.txt/.md file"
value={content} onChange={e => setContent(e.target.value)} style={input} />
<div style={{ display: "flex", gap: "0.7rem", alignItems: "center" }}>
<input type="file" accept=".pdf,.docx,.txt,.md,.markdown,text/*" onChange={onFile} disabled={busy}
title="Upload a file — text extracted server-side"
style={{ color: "#aaa", flex: 1 }} />
<button onClick={addDoc} disabled={busy}
style={{ ...btn, padding: "0.9rem 1.5rem", background: busy ? "#555" : "#007acc", cursor: busy ? "default" : "pointer" }}>
{busy ? "Indexing…" : "Add document"}
</button>
</div>
{message && <div style={{ color: "#8ab4ff" }}>{message}</div>}
</div>
</Modal>
)}
{picking && (
<Modal title="Add an existing chat" onClose={() => setPicking(null)}>
{picking.length === 0 ? (
<div style={{ color: "#777" }}>No other chats.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{picking.map(c => (
<div key={c.id} style={card}>
<div style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label(c)}</div>
<button onClick={() => addChat(c.id)} style={ghost}>Add</button>
</div>
))}
</div>
)}
</Modal>
)}
{viewing && (
<Modal title={viewing.title} onClose={() => setViewing(null)}>
{viewing.chunks.map(c => (
<div key={c.chunk_idx} style={{ marginBottom: "1rem" }}>
<div style={{ color: "#666", fontSize: "0.75rem", marginBottom: "0.3rem" }}>chunk {c.chunk_idx + 1}</div>
<div style={{ whiteSpace: "pre-wrap", color: "#ddd", fontSize: "0.9rem", background: "#141414", border: "1px solid #262626", borderRadius: "8px", padding: "0.7rem" }}>{c.text}</div>
</div>
))}
</Modal>
)}
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
import { MailAccounts } from "./MailAccounts";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const accountLabel = (a) => a.label || a.from_addr || a.username || "(unnamed)";
export function Mail() {
const [accounts, setAccounts] = useState(null); // null until loaded
const [expanded, setExpanded] = useState({}); // {accountId: bool}
const [foldersByAccount, setFoldersByAccount] = useState({}); // {accountId: [folders]}
const [selection, setSelection] = useState(null); // {accountId, folder}
const [messages, setMessages] = useState([]);
const [selected, setSelected] = useState(null); // full message
const [composing, setComposing] = useState(null); // {accountId,to,cc,subject,body}
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const [showAccounts, setShowAccounts] = useState(false);
const loadFoldersFor = async (accountId) => {
const r = await fetch(`${API_BASE}/mail/folders?account_id=${encodeURIComponent(accountId)}`);
const folders = r.ok ? (await r.json()).folders || [] : [];
setFoldersByAccount(prev => ({ ...prev, [accountId]: folders }));
return folders;
};
const loadMessages = async (sel = selection) => {
if (!sel) return;
setBusy(true); setSelected(null);
try {
const r = await fetch(`${API_BASE}/mail/messages?account_id=${encodeURIComponent(sel.accountId)}&folder=${encodeURIComponent(sel.folder)}&limit=40`);
const data = r.ok ? await r.json() : null;
setMessages(data ? data.messages || [] : []);
if (!r.ok) setNote((await r.json().catch(() => ({}))).detail || "Failed to load messages.");
} finally { setBusy(false); }
};
const bootstrap = async () => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
const configured = list.filter(a => a.configured);
setExpanded(Object.fromEntries(list.map(a => [a.id, true])));
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { bootstrap(); }, []);
const onAccountsChanged = async (list) => {
setAccounts(list);
setExpanded(prev => ({ ...Object.fromEntries(list.map(a => [a.id, true])), ...prev }));
const configured = list.filter(a => a.configured);
await Promise.all(configured.map(a => loadFoldersFor(a.id)));
// if the active selection's account vanished, fall back to the first configured one
if (selection && !configured.some(a => a.id === selection.accountId)) {
if (configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
} else {
setSelection(null); setMessages([]); setSelected(null);
}
} else if (!selection && configured.length) {
const sel = { accountId: configured[0].id, folder: "INBOX" };
setSelection(sel);
loadMessages(sel);
}
};
const openMessage = async (m) => {
setBusy(true);
try {
const r = await fetch(`${API_BASE}/mail/message?account_id=${encodeURIComponent(selection.accountId)}&folder=${encodeURIComponent(selection.folder)}&uid=${encodeURIComponent(m.uid)}`);
if (r.ok) { setSelected(await r.json()); setMessages(prev => prev.map(x => x.uid === m.uid ? { ...x, seen: true } : x)); }
} finally { setBusy(false); }
};
const deleteMessage = async (m) => {
if (!window.confirm("Delete this message?")) return;
await fetch(`${API_BASE}/mail/delete`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: selection.accountId, folder: selection.folder, uid: m.uid }),
});
setSelected(null); loadMessages();
};
const sendCompose = async () => {
setBusy(true); setNote("Sending…");
try {
const r = await fetch(`${API_BASE}/mail/send`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(composing),
});
const d = await r.json().catch(() => ({}));
if (r.ok) { setNote(`Sent to ${(d.to || []).join(", ")}.`); setComposing(null); }
else setNote(d.detail || "Send failed.");
} finally { setBusy(false); }
};
const startCompose = () => setComposing({
accountId: (selection && selection.accountId) || (accounts || []).find(a => a.configured)?.id || "",
to: "", cc: "", subject: "", body: "",
});
const reply = (m) => setComposing({
accountId: selection.accountId,
to: m.from, cc: "", subject: /^re:/i.test(m.subject || "") ? m.subject : `Re: ${m.subject || ""}`,
body: `\n\n---\nOn ${m.date ? new Date(m.date).toLocaleString() : ""}, ${m.from} wrote:\n${(m.text || "").slice(0, 2000)}`,
});
const selectFolder = (accountId, folder) => {
const sel = { accountId, folder };
setSelection(sel);
loadMessages(sel);
};
const toggleExpanded = (accountId) => setExpanded(prev => ({ ...prev, [accountId]: !prev[accountId] }));
const configuredAccounts = (accounts || []).filter(a => a.configured);
if (accounts === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
// ---- empty state: no configured accounts yet ----
if (configuredAccounts.length === 0) {
return (
<div style={{ padding: "1.5rem", color: "#eee", maxWidth: "560px" }}>
<h2 style={{ marginTop: 0 }}> Mail</h2>
<p style={{ color: "#aaa" }}>No mail accounts configured yet.</p>
<button onClick={() => setShowAccounts(true)} style={btn("#007acc")}>+ Add mail account</button>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
// ---- client ----
return (
<div style={{ display: "flex", height: "100%", color: "#eee" }}>
<div style={{ width: "200px", borderRight: "1px solid #2a2a2a", padding: "0.75rem", overflowY: "auto", flexShrink: 0, display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", gap: "0.4rem", marginBottom: "0.75rem" }}>
<button onClick={startCompose} style={{ ...btn("#007acc"), flexGrow: 1 }}> Compose</button>
<button onClick={() => setShowAccounts(true)} title="Manage accounts"
style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", padding: "0.5rem 0.6rem" }}></button>
</div>
<div style={{ flexGrow: 1, overflowY: "auto" }}>
{(accounts || []).map(a => (
<div key={a.id} style={{ marginBottom: "0.5rem" }}>
<div onClick={() => toggleExpanded(a.id)}
style={{ display: "flex", alignItems: "center", gap: "0.35rem", padding: "0.3rem 0.3rem", cursor: "pointer", color: a.configured ? "#ddd" : "#c9a227", fontSize: "0.82rem", fontWeight: 600 }}>
<span style={{ display: "inline-block", width: "0.8em", transform: expanded[a.id] ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}></span>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{accountLabel(a)}</span>
</div>
{expanded[a.id] && (
!a.configured ? (
<div style={{ padding: "0.2rem 0.5rem 0.2rem 1.4rem", fontSize: "0.75rem", color: "#c9a227" }}>needs password to fix</div>
) : (foldersByAccount[a.id] || []).map(f => {
const isActive = selection && selection.accountId === a.id && selection.folder === f;
return (
<div key={f} onClick={() => selectFolder(a.id, f)}
style={{ padding: "0.32rem 0.5rem 0.32rem 1.4rem", borderRadius: "6px", cursor: "pointer", fontSize: "0.8rem", background: isActive ? "#1c2a3a" : "transparent", color: isActive ? "#fff" : "#aaa", marginBottom: "0.1rem", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{f}
</div>
);
})
)}
</div>
))}
</div>
</div>
<div style={{ width: "320px", borderRight: "1px solid #2a2a2a", overflowY: "auto", flexShrink: 0 }}>
<div style={{ padding: "0.5rem 0.75rem", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #2a2a2a" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{selection ? selection.folder : "—"}
</span>
<button onClick={() => loadMessages()} disabled={!selection} style={{ background: "none", border: "none", color: "#888", cursor: "pointer" }}></button>
</div>
{!selection ? <div style={{ padding: "1rem", color: "#777" }}>Select a folder.</div>
: busy && messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>Loading</div>
: messages.length === 0 ? <div style={{ padding: "1rem", color: "#777" }}>{note || "No messages."}</div>
: messages.map(m => (
<div key={m.uid} onClick={() => openMessage(m)}
style={{ padding: "0.6rem 0.75rem", borderBottom: "1px solid #1e1e1e", cursor: "pointer", background: selected && selected.uid === m.uid ? "#1c2a3a" : "transparent" }}>
<div style={{ fontSize: "0.8rem", color: m.seen ? "#999" : "#fff", fontWeight: m.seen ? 400 : 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.from}</div>
<div style={{ fontSize: "0.82rem", color: m.seen ? "#aaa" : "#eee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.subject || "(no subject)"}</div>
<div style={{ fontSize: "0.72rem", color: "#666", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{m.preview}</div>
</div>
))}
</div>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "1rem", minWidth: 0 }}>
{composing ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "640px" }}>
<h3 style={{ margin: 0 }}>New message</h3>
{configuredAccounts.length > 1 && (
<div>
<label style={{ display: "block", fontSize: "0.75rem", color: "#888", marginBottom: "0.3rem" }}>From</label>
<select value={composing.accountId} onChange={e => setComposing({ ...composing, accountId: e.target.value })}
style={{ ...inputStyle, width: "100%" }}>
{configuredAccounts.map(a => <option key={a.id} value={a.id}>{accountLabel(a)} {a.from_addr || a.username}</option>)}
</select>
</div>
)}
<input placeholder="To" value={composing.to} onChange={e => setComposing({ ...composing, to: e.target.value })} style={inputStyle} />
<input placeholder="Cc" value={composing.cc} onChange={e => setComposing({ ...composing, cc: e.target.value })} style={inputStyle} />
<input placeholder="Subject" value={composing.subject} onChange={e => setComposing({ ...composing, subject: e.target.value })} style={inputStyle} />
<textarea rows={14} placeholder="Body" value={composing.body} onChange={e => setComposing({ ...composing, body: e.target.value })} style={inputStyle} />
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button onClick={sendCompose} disabled={busy || !composing.to.trim()} style={btn(busy || !composing.to.trim() ? "#555" : "#007acc")}>Send</button>
<button onClick={() => setComposing(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>Cancel</button>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
) : selected ? (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem" }}>{selected.subject || "(no subject)"}</h3>
<div style={{ display: "flex", gap: "0.4rem", flexShrink: 0 }}>
<button onClick={() => reply(selected)} style={btn("#2a4a6a")}>Reply</button>
<button onClick={() => deleteMessage(selected)} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>
</div>
</div>
<div style={{ color: "#aaa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>
<div><b>From:</b> {selected.from}</div>
<div><b>To:</b> {(selected.to || []).join(", ")}</div>
{selected.date && <div>{new Date(selected.date).toLocaleString()}</div>}
</div>
{/* Sandboxed iframe: no scripts, no same-origin — email HTML can't touch the app. */}
{selected.html
? <iframe title="message" sandbox="" srcDoc={selected.html} style={{ width: "100%", height: "60vh", border: "1px solid #2a2a2a", borderRadius: "8px", background: "#fff" }} />
: <pre style={{ whiteSpace: "pre-wrap", fontFamily: "system-ui", color: "#ddd" }}>{selected.text || "(empty)"}</pre>}
{selected.attachments && selected.attachments.length > 0 && (
<div style={{ marginTop: "0.75rem", color: "#888", fontSize: "0.8rem" }}>
📎 {selected.attachments.map(a => a.name).join(", ")}
</div>
)}
</div>
) : (
<div style={{ color: "#777", paddingTop: "2rem", textAlign: "center" }}>Select a message.</div>
)}
</div>
{showAccounts && <MailAccounts onClose={() => setShowAccounts(false)} onChanged={onAccountsChanged} />}
</div>
);
}
@@ -0,0 +1,182 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", width: "100%", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const labelStyle = { display: "block", fontSize: "0.72rem", color: "#888", marginBottom: "0.3rem", textTransform: "uppercase", letterSpacing: "0.05em" };
const BLANK = {
label: "", username: "", password: "", from_addr: "", from_name: "",
imap_host: "imap.mail.me.com", imap_port: 993, smtp_host: "smtp.mail.me.com", smtp_port: 587,
};
// macOS-Mail-style "Internet Accounts" window: accounts on the left, the
// selected account's config on the right. Lets you manage several mailboxes
// that happen to share the same IMAP/SMTP server.
export function MailAccounts({ onClose, onChanged }) {
const [accounts, setAccounts] = useState(null); // null = loading
const [selectedId, setSelectedId] = useState(null); // null = "add account" form
const [form, setForm] = useState(BLANK);
const [busy, setBusy] = useState(false);
const [note, setNote] = useState("");
const selectAccount = (id, list = accounts) => {
setNote("");
setSelectedId(id);
if (id === null) { setForm(BLANK); return; }
const a = (list || []).find(x => x.id === id);
if (a) setForm({ ...a, password: "" });
};
const load = async (selectAfter) => {
const r = await fetch(`${API_BASE}/mail/accounts`);
const list = r.ok ? (await r.json()).accounts || [] : [];
setAccounts(list);
onChanged && onChanged(list);
if (selectAfter !== undefined) selectAccount(selectAfter, list);
return list;
};
useEffect(() => {
load().then(list => { if (list.length) selectAccount(list[0].id, list); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const save = async () => {
setBusy(true); setNote("Saving…");
try {
const url = selectedId ? `${API_BASE}/mail/accounts/${selectedId}` : `${API_BASE}/mail/accounts`;
const r = await fetch(url, {
method: selectedId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!r.ok) { setNote("Save failed."); return; }
const saved = await r.json();
setNote("Saved.");
await load(saved.id);
} finally { setBusy(false); }
};
const test = async () => {
if (!selectedId) { setNote("Save the account first."); return; }
setBusy(true); setNote("Testing…");
try {
const r = await fetch(`${API_BASE}/mail/accounts/${selectedId}/test`, { method: "POST" });
const d = await r.json().catch(() => ({}));
setNote(d.ok ? `Connected — ${d.folders} folders.` : `Connection failed: ${d.error || "check credentials"}`);
} finally { setBusy(false); }
};
const remove = async () => {
if (!selectedId) return;
if (!window.confirm(`Delete account "${form.label || form.from_addr || form.username}"?`)) return;
setBusy(true);
try {
await fetch(`${API_BASE}/mail/accounts/${selectedId}`, { method: "DELETE" });
const list = await load();
selectAccount(list.length ? list[0].id : null, list);
} finally { setBusy(false); }
};
const update = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
<div onClick={onClose}
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: "2rem" }}>
<div onClick={e => e.stopPropagation()}
style={{ background: "#1a1a1a", border: "1px solid #333", borderRadius: "12px", width: "720px", maxWidth: "100%", height: "520px", maxHeight: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.85rem 1.1rem", borderBottom: "1px solid #2a2a2a", flexShrink: 0 }}>
<strong>Mail Accounts</strong>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#aaa", fontSize: "1.2rem", cursor: "pointer" }}></button>
</div>
<div style={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
{/* Left: accounts bar */}
<div style={{ width: "200px", flexShrink: 0, borderRight: "1px solid #2a2a2a", display: "flex", flexDirection: "column" }}>
<div style={{ flexGrow: 1, overflowY: "auto", padding: "0.5rem" }}>
{accounts === null ? (
<div style={{ color: "#666", fontSize: "0.82rem", padding: "0.5rem" }}>Loading</div>
) : accounts.length === 0 ? (
<div style={{ color: "#666", fontSize: "0.8rem", padding: "0.5rem" }}>No accounts yet.</div>
) : accounts.map(a => (
<div key={a.id} onClick={() => selectAccount(a.id)}
style={{
padding: "0.5rem 0.6rem", borderRadius: "6px", cursor: "pointer", marginBottom: "0.2rem",
background: selectedId === a.id ? "#1c2a3a" : "transparent",
color: selectedId === a.id ? "#fff" : "#ccc",
}}>
<div style={{ fontSize: "0.85rem", fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.label || a.from_addr || a.username || "(unnamed)"}
</div>
<div style={{ fontSize: "0.72rem", color: a.configured ? "#8aff8a" : "#c9a227", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{a.configured ? (a.from_addr || a.username) : "needs password"}
</div>
</div>
))}
</div>
<div style={{ padding: "0.5rem", borderTop: "1px solid #2a2a2a" }}>
<button onClick={() => selectAccount(null)} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc", width: "100%" }}>+ Add account</button>
</div>
</div>
{/* Right: selected account form */}
<div style={{ flexGrow: 1, padding: "1.1rem", overflowY: "auto" }}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#eee" }}>
{selectedId ? "Edit account" : "New account"}
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", maxWidth: "440px" }}>
<div>
<label style={labelStyle}>Nickname (optional)</label>
<input value={form.label} onChange={e => update("label", e.target.value)} placeholder="e.g. Personal, Work" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Apple ID (login)</label>
<input value={form.username} onChange={e => update("username", e.target.value)} placeholder="you@icloud.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{selectedId ? "App-specific password (blank = keep current)" : "App-specific password"}</label>
<input type="password" value={form.password} onChange={e => update("password", e.target.value)} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From address</label>
<input value={form.from_addr} onChange={e => update("from_addr", e.target.value)} placeholder="nexus@enderofwings.com" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>From name (optional)</label>
<input value={form.from_name} onChange={e => update("from_name", e.target.value)} style={inputStyle} />
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>IMAP host</label>
<input value={form.imap_host} onChange={e => update("imap_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.imap_port} onChange={e => update("imap_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem" }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>SMTP host</label>
<input value={form.smtp_host} onChange={e => update("smtp_host", e.target.value)} style={inputStyle} />
</div>
<div style={{ width: "90px" }}>
<label style={labelStyle}>Port</label>
<input type="number" value={form.smtp_port} onChange={e => update("smtp_port", parseInt(e.target.value) || 0)} style={inputStyle} />
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginTop: "0.4rem" }}>
<button onClick={save} disabled={busy} style={btn(busy ? "#555" : "#007acc")}>{selectedId ? "Save" : "Create"}</button>
{selectedId && <button onClick={test} disabled={busy} style={btn("#2a4a6a")}>Test connection</button>}
{selectedId && <button onClick={remove} disabled={busy} style={{ ...btn("#3a1a1a"), color: "#ff8a80" }}>Delete</button>}
</div>
<span style={{ color: "#8ab4ff", fontSize: "0.85rem" }}>{note}</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Mail as Component } from "./Mail";
export const MANIFEST = { key: "mail", label: "Mail", icon: "✉️", order: 1 };
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { API_BASE } from "../../config";
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" };
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
const sectionStyle = { background: "#161616", border: "1px solid #333", borderRadius: "12px", padding: "1.25rem", marginBottom: "1rem" };
const dot = (ok) => ({
display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0,
background: ok === null ? "#555" : ok ? "#4caf50" : "#f44336",
});
export function Network() {
const [status, setStatus] = useState(null); // {hostname, connection, vpn}
const [targets, setTargets] = useState(null); // [{id,label,host,ok,latency_ms}]
const [newLabel, setNewLabel] = useState("");
const [newHost, setNewHost] = useState("");
const [busy, setBusy] = useState(false);
const [vpnBusy, setVpnBusy] = useState(false);
const [note, setNote] = useState("");
const loadStatus = async () => {
const r = await fetch(`${API_BASE}/network/status`);
if (r.ok) setStatus(await r.json());
};
const loadAndPingTargets = async () => {
const r = await fetch(`${API_BASE}/network/ping`);
setTargets(r.ok ? (await r.json()).targets || [] : []);
};
const refresh = async () => {
setBusy(true);
try { await Promise.all([loadStatus(), loadAndPingTargets()]); }
finally { setBusy(false); }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { refresh(); }, []);
const toggleVpn = async () => {
if (!status || !status.vpn.configured) return;
setVpnBusy(true); setNote("");
try {
const r = await fetch(`${API_BASE}/network/vpn/toggle`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable: !status.vpn.connected }),
});
const d = await r.json().catch(() => ({}));
if (r.ok) setStatus(s => ({ ...s, vpn: d }));
else setNote(d.detail || "VPN toggle failed.");
} finally { setVpnBusy(false); }
};
const addTarget = async () => {
if (!newHost.trim()) return;
setBusy(true);
try {
const r = await fetch(`${API_BASE}/network/targets`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newLabel.trim(), host: newHost.trim() }),
});
if (r.ok) { setNewLabel(""); setNewHost(""); await loadAndPingTargets(); }
} finally { setBusy(false); }
};
const removeTarget = async (id) => {
setBusy(true);
try {
await fetch(`${API_BASE}/network/targets/${id}`, { method: "DELETE" });
await loadAndPingTargets();
} finally { setBusy(false); }
};
if (status === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading</div>;
const conn = status.connection;
const vpn = status.vpn;
return (
<div style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<h2 style={{ margin: 0, fontSize: "1.1rem", color: "#eee" }}>📡 Network</h2>
<button onClick={refresh} disabled={busy} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>
{busy ? "Refreshing…" : "⟳ Refresh"}
</button>
</div>
{/* Connection */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>Connection</h3>
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", fontSize: "0.88rem" }}>
<div><span style={{ color: "#666" }}>Host</span><div style={{ color: "#eee" }}>{status.hostname}</div></div>
<div><span style={{ color: "#666" }}>Type</span><div style={{ color: "#eee", textTransform: "capitalize" }}>{conn.type}</div></div>
<div><span style={{ color: "#666" }}>Interface</span><div style={{ color: "#eee" }}>{conn.interface || "—"}</div></div>
<div><span style={{ color: "#666" }}>IP</span><div style={{ color: "#eee" }}>{conn.ip || "—"}</div></div>
</div>
</div>
{/* VPN */}
{vpn.available && (
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>WireGuard VPN</h3>
{!vpn.configured ? (
<p style={{ margin: 0, fontSize: "0.85rem", color: "#666" }}>No WireGuard tunnel configured in NetworkManager.</p>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={dot(vpn.connected)} />
<span style={{ fontSize: "0.88rem", color: "#ccc" }}>{vpn.name}</span>
<span style={{ fontSize: "0.82rem", color: vpn.connected ? "#4caf50" : "#888" }}>
{vpn.connected ? "Connected" : "Disconnected"}
</span>
<button onClick={toggleVpn} disabled={vpnBusy}
style={{ ...btn(vpn.connected ? "#2a1a1a" : "#152a15"), color: vpn.connected ? "#ff8a80" : "#8aff8a", marginLeft: "auto" }}>
{vpnBusy ? "Working…" : vpn.connected ? "Disconnect" : "Connect"}
</button>
</div>
)}
{note && <p style={{ margin: "0.5rem 0 0", color: "#f44336", fontSize: "0.82rem" }}>{note}</p>}
</div>
)}
{/* Ping targets */}
<div style={sectionStyle}>
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Ping targets</h3>
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
Hosts to check reachability for a router, a VPN endpoint, anything on your network.
</p>
{(targets || []).length === 0 ? (
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "1rem" }}>No targets yet.</div>
) : (
<div style={{ marginBottom: "1rem" }}>
{targets.map(t => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.45rem 0", borderBottom: "1px solid #222" }}>
<span style={dot(t.ok)} />
<span style={{ fontSize: "0.88rem", color: "#eee", minWidth: "120px" }}>{t.label}</span>
<span style={{ fontSize: "0.82rem", color: "#888" }}>{t.host}</span>
<span style={{ fontSize: "0.8rem", color: "#666", marginLeft: "auto" }}>
{t.ok ? (t.latency_ms != null ? `${t.latency_ms.toFixed(0)} ms` : "reachable") : t.ok === false ? "unreachable" : ""}
</span>
<button onClick={() => removeTarget(t.id)} disabled={busy}
style={{ background: "transparent", border: "none", color: "#888", cursor: "pointer", fontSize: "0.9rem", padding: "0 4px" }}></button>
</div>
))}
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input placeholder="Label (optional)" value={newLabel} onChange={e => setNewLabel(e.target.value)}
style={{ ...inputStyle, width: "160px" }} />
<input placeholder="Host or IP" value={newHost} onChange={e => setNewHost(e.target.value)}
onKeyDown={e => e.key === "Enter" && addTarget()} style={{ ...inputStyle, width: "200px" }} />
<button onClick={addTarget} disabled={busy || !newHost.trim()} style={btn(!newHost.trim() ? "#555" : "#007acc")}>+ Add</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
// Entry point picked up by ../registry.js's auto-discovery every module
// folder needs one of these exporting MANIFEST + Component. This file is
// registration glue, not itself a hot-reloaded component.
/* eslint-disable react-refresh/only-export-components */
export { Network as Component } from "./Network";
export const MANIFEST = { key: "network", label: "Network", icon: "📡", order: 2 };
+10
View File
@@ -0,0 +1,10 @@
// Auto-discovers feature modules under modules/ — any folder with a
// module.jsx exporting MANIFEST ({key,label,icon,order}) + Component is
// picked up automatically. Add a new module folder and it appears in the
// Modules hover menu with no edits here.
const discovered = import.meta.glob("./*/module.jsx", { eager: true });
export const MODULES = Object.values(discovered)
.filter(m => m.MANIFEST && m.Component)
.map(m => ({ ...m.MANIFEST, Component: m.Component }))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));