forked from enderofwings/NexusOS
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
683 lines
27 KiB
React
683 lines
27 KiB
React
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Chatbot } from "./Chatbot";
|
|
import { Playbook } from "./Playbook";
|
|
import { Models } from "./Models";
|
|
import { Settings } from "./Settings";
|
|
import { Memory } from "./Memory";
|
|
import { Projects } from "./Projects";
|
|
import { Logs } from "./Logs";
|
|
import { MODULES } from "./modules/registry";
|
|
|
|
import { API_BASE } from "./config";
|
|
|
|
function timeAgo(epochSeconds) {
|
|
const diff = Math.floor(Date.now() / 1000) - epochSeconds;
|
|
if (diff < 60) return "just now";
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
|
return new Date(epochSeconds * 1000).toLocaleDateString();
|
|
}
|
|
|
|
function App() {
|
|
const [currentPage, setCurrentPage] = useState("chatbot");
|
|
const [status, setStatus] = useState("Checking Synapse...");
|
|
const [version, setVersion] = useState("");
|
|
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);
|
|
const [conversations, setConversations] = useState([]);
|
|
const [search, setSearch] = useState("");
|
|
const [hoveredConvId, setHoveredConvId] = useState(null);
|
|
const searchDebounce = useRef(null);
|
|
|
|
const loadStatus = () => {
|
|
fetch(`${API_BASE}/status`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
setStatus(d.status ?? "Unknown");
|
|
setVersion(d.version ?? "");
|
|
setOllamaStatus(d.ollama ?? "unavailable");
|
|
})
|
|
.catch(() => {
|
|
setStatus("Offline");
|
|
setOllamaStatus("unavailable");
|
|
});
|
|
};
|
|
|
|
// 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";
|
|
setOllamaBusy(true);
|
|
try {
|
|
await fetch(`${API_BASE}/ollama/${action}`, { method: "POST" });
|
|
} catch (e) {
|
|
console.error("Ollama toggle failed:", e);
|
|
}
|
|
setOllamaBusy(false);
|
|
loadStatus();
|
|
};
|
|
|
|
const loadConversations = useCallback(async (q = "") => {
|
|
try {
|
|
const url = q.trim()
|
|
? `${API_BASE}/conversations?q=${encodeURIComponent(q.trim())}`
|
|
: `${API_BASE}/conversations`;
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setConversations(data.conversations || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load conversations:", error);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadStatus();
|
|
const interval = setInterval(loadStatus, 5000);
|
|
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`)
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
if (cancelled || !data) return;
|
|
setConversations(data.conversations || []);
|
|
})
|
|
.catch(err => console.error("Failed to load conversations:", err));
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const handleSearch = (e) => {
|
|
const q = e.target.value;
|
|
setSearch(q);
|
|
clearTimeout(searchDebounce.current);
|
|
searchDebounce.current = setTimeout(() => loadConversations(q), 300);
|
|
};
|
|
|
|
const selectConversation = (id) => {
|
|
setActiveConversationId(id);
|
|
setCurrentPage("chatbot");
|
|
};
|
|
|
|
const startNewChat = () => {
|
|
setActiveConversationId(crypto.randomUUID());
|
|
setCurrentPage("chatbot");
|
|
};
|
|
|
|
const renameConversation = async (e, conv) => {
|
|
e.stopPropagation();
|
|
const current = conv.title || conv.preview || "";
|
|
const next = window.prompt("Rename conversation:", current);
|
|
if (next == null) return;
|
|
const title = next.trim();
|
|
if (!title || title === current) return;
|
|
try {
|
|
const response = await fetch(`${API_BASE}/conversations/${conv.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
if (response.ok) loadConversations(search);
|
|
} catch (error) {
|
|
console.error("Failed to rename conversation:", error);
|
|
}
|
|
};
|
|
|
|
// Download ShareGPT JSONL — one conversation if convId given, else all.
|
|
const exportConversations = (e, convId) => {
|
|
if (e) e.stopPropagation();
|
|
const url = convId
|
|
? `${API_BASE}/conversations/export?conversation_id=${encodeURIComponent(convId)}`
|
|
: `${API_BASE}/conversations/export`;
|
|
window.open(url, "_blank");
|
|
};
|
|
|
|
const deleteConversation = async (e, conversationId) => {
|
|
e.stopPropagation();
|
|
if (!window.confirm("Delete this conversation?")) return;
|
|
try {
|
|
const response = await fetch(`${API_BASE}/conversations/${conversationId}`, { method: "DELETE" });
|
|
if (response.ok) {
|
|
if (activeConversationId === conversationId) {
|
|
setActiveConversationId(crypto.randomUUID());
|
|
}
|
|
loadConversations(search);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to delete conversation:", error);
|
|
}
|
|
};
|
|
|
|
// Bulk delete of everything currently listed (respects the search filter).
|
|
const clearConversations = async () => {
|
|
const n = conversations.length;
|
|
if (!n) return;
|
|
const what = search ? `${n} matching conversation${n > 1 ? "s" : ""}` : `all ${n} conversation${n > 1 ? "s" : ""}`;
|
|
if (!window.confirm(`Delete ${what}? This cannot be undone.`)) return;
|
|
const ids = conversations.map(c => c.id);
|
|
await Promise.all(
|
|
ids.map(id => fetch(`${API_BASE}/conversations/${id}`, { method: "DELETE" }).catch(err =>
|
|
console.error("Failed to delete conversation:", err)))
|
|
);
|
|
if (ids.includes(activeConversationId)) setActiveConversationId(crypto.randomUUID());
|
|
loadConversations(search);
|
|
};
|
|
|
|
const navItems = [
|
|
{ key: "chatbot", icon: "💬", label: "Chat" },
|
|
{ key: "playbook", icon: "📖", label: "Playbooks" },
|
|
{ key: "models", icon: "🤖", label: "Models", badge: isModelPulling },
|
|
{ key: "memory", icon: "🧠", label: "Memory" },
|
|
{ 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",
|
|
flexShrink: 0,
|
|
background: "#0a0a0a",
|
|
borderRight: "1px solid #333",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 0,
|
|
}}>
|
|
{/* Top cell: brand + nav */}
|
|
<div style={{
|
|
padding: "0.75rem 0.6rem 0.65rem",
|
|
borderBottom: "1px solid #1f1f1f",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: "0.4rem",
|
|
flexShrink: 0,
|
|
}}>
|
|
<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
|
|
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 */}
|
|
<div style={{ position: "relative" }}>
|
|
<div
|
|
style={{
|
|
width: "8px",
|
|
height: "8px",
|
|
borderRadius: "50%",
|
|
background: status === "Offline" || ollamaStatus === "unavailable" ? "#f44336"
|
|
: ollamaStatus === "running" ? "#4caf50" : "#ff9800",
|
|
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
|
|
transition: "all 0.3s"
|
|
}}
|
|
onMouseEnter={() => {
|
|
loadStatus();
|
|
setShowStatusTooltip(true);
|
|
}}
|
|
onMouseLeave={() => setShowStatusTooltip(false)}
|
|
onClick={() => {
|
|
if (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") return;
|
|
toggleOllama();
|
|
}}
|
|
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Click to start/stop the AI"}
|
|
/>
|
|
{showStatusTooltip && (
|
|
<div style={{
|
|
position: "absolute",
|
|
top: "16px",
|
|
left: "50%",
|
|
transform: "translateX(-50%)",
|
|
background: "#1a1a1a",
|
|
border: "1px solid #333",
|
|
borderRadius: "6px",
|
|
padding: "0.6rem 0.8rem",
|
|
fontSize: "0.8rem",
|
|
whiteSpace: "nowrap",
|
|
zIndex: 1000,
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.5)"
|
|
}}>
|
|
<div style={{ marginBottom: "0.3rem", color: status === "Offline" ? "#f44336" : "#4caf50" }}>
|
|
Synapse: <strong>{status}</strong>
|
|
</div>
|
|
<div style={{ color: ollamaStatus === "unavailable" ? "#f44336" : ollamaStatus === "running" ? "#4caf50" : "#aaa" }}>
|
|
{/* Naming the phase matters: nearly all of the wait is the model
|
|
being read off disk (GBs), not the server booting. */}
|
|
Ollama: <strong>{ollamaBusy ? (ollamaStatus === "running" ? "stopping…" : "loading model…") : ollamaStatus}</strong>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Start/Stop AI moved onto the status dot above — click it to toggle. */}
|
|
|
|
<nav style={{ display: "flex", flexDirection: "column", width: "100%", marginTop: "0.1rem" }}>
|
|
{navItems.map(item => {
|
|
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={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>
|
|
{item.badge && (
|
|
<span style={{
|
|
width: "6px",
|
|
height: "6px",
|
|
borderRadius: "50%",
|
|
background: "#28a745",
|
|
flexShrink: 0,
|
|
animation: "pulse 1.2s ease-in-out infinite",
|
|
}} />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Bottom cell: conversation list */}
|
|
<div style={{
|
|
flexGrow: 1,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
padding: "0.6rem 0.6rem 0.6rem",
|
|
}}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem", flexShrink: 0 }}>
|
|
<span style={{ fontSize: "0.75rem", color: "#888", textTransform: "uppercase", letterSpacing: "0.05em" }}>Chats</span>
|
|
<div style={{ display: "flex", gap: "0.35rem" }}>
|
|
<button
|
|
onClick={() => exportConversations(null, null)}
|
|
title="Export all conversations (ShareGPT JSONL)"
|
|
disabled={conversations.length === 0}
|
|
style={{
|
|
padding: "0.2rem 0.5rem",
|
|
background: "#161616",
|
|
color: conversations.length === 0 ? "#555" : "#bbb",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
cursor: conversations.length === 0 ? "default" : "pointer",
|
|
fontSize: "0.75rem",
|
|
}}
|
|
>
|
|
⤓ Export
|
|
</button>
|
|
<button
|
|
onClick={clearConversations}
|
|
title={search ? "Delete the conversations matching this search" : "Delete all conversations"}
|
|
disabled={conversations.length === 0}
|
|
style={{
|
|
padding: "0.2rem 0.5rem",
|
|
background: "#161616",
|
|
color: conversations.length === 0 ? "#555" : "#c66",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
cursor: conversations.length === 0 ? "default" : "pointer",
|
|
fontSize: "0.75rem",
|
|
}}
|
|
>
|
|
🗑 Clear
|
|
</button>
|
|
<button
|
|
onClick={startNewChat}
|
|
title="New chat"
|
|
style={{
|
|
padding: "0.2rem 0.55rem",
|
|
background: "#161616",
|
|
color: "#bbb",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
cursor: "pointer",
|
|
fontSize: "0.75rem",
|
|
}}
|
|
>
|
|
+ New
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={handleSearch}
|
|
style={{
|
|
width: "100%",
|
|
padding: "0.45rem 0.6rem",
|
|
background: "#161616",
|
|
color: "#eee",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
marginBottom: "0.5rem",
|
|
boxSizing: "border-box",
|
|
fontSize: "0.8rem",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<div style={{ flexGrow: 1, overflowY: "auto", minHeight: 0 }}>
|
|
{conversations.length === 0 ? (
|
|
<p style={{ color: "#555", fontSize: "0.8rem", textAlign: "center", marginTop: "0.5rem" }}>
|
|
{search ? "No matches." : "No conversations yet."}
|
|
</p>
|
|
) : (
|
|
conversations.map((conv) => {
|
|
const isActive = activeConversationId === conv.id;
|
|
const isHover = hoveredConvId === conv.id;
|
|
return (
|
|
<div
|
|
key={conv.id}
|
|
onClick={() => selectConversation(conv.id)}
|
|
onMouseEnter={() => setHoveredConvId(conv.id)}
|
|
onMouseLeave={() => setHoveredConvId(null)}
|
|
style={{
|
|
padding: "0.55rem 0.65rem",
|
|
marginBottom: "0.3rem",
|
|
borderRadius: "6px",
|
|
cursor: "pointer",
|
|
background: isHover ? "#161616" : "transparent",
|
|
border: "1px solid transparent",
|
|
borderLeft: `2px solid ${isActive ? "#007acc" : "transparent"}`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.4rem",
|
|
}}
|
|
>
|
|
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: "0.82rem",
|
|
color: isActive ? "#4aa3e0" : "#ccc",
|
|
fontWeight: isActive ? 600 : 400,
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}>
|
|
{conv.title || conv.preview || "Conversation"}
|
|
</div>
|
|
<div style={{ fontSize: "0.7rem", color: "#666", marginTop: "0.1rem" }}>
|
|
{timeAgo(conv.timestamp)}
|
|
</div>
|
|
</div>
|
|
{isHover && (
|
|
<>
|
|
<button
|
|
onClick={(e) => exportConversations(e, conv.id)}
|
|
title="Export this conversation"
|
|
style={{
|
|
padding: "0.15rem 0.4rem",
|
|
background: "transparent",
|
|
color: "#888",
|
|
border: "1px solid #333",
|
|
borderRadius: "4px",
|
|
cursor: "pointer",
|
|
fontSize: "0.7rem",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
📤
|
|
</button>
|
|
<button
|
|
onClick={(e) => renameConversation(e, conv)}
|
|
title="Rename"
|
|
style={{
|
|
padding: "0.15rem 0.4rem",
|
|
background: "transparent",
|
|
color: "#888",
|
|
border: "1px solid #333",
|
|
borderRadius: "4px",
|
|
cursor: "pointer",
|
|
fontSize: "0.7rem",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
✎
|
|
</button>
|
|
<button
|
|
onClick={(e) => deleteConversation(e, conv.id)}
|
|
title="Delete"
|
|
style={{
|
|
padding: "0.15rem 0.4rem",
|
|
background: "transparent",
|
|
color: "#888",
|
|
border: "1px solid #333",
|
|
borderRadius: "4px",
|
|
cursor: "pointer",
|
|
fontSize: "0.7rem",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main style={{ flexGrow: 1, padding: "1.5rem", display: "flex", flexDirection: "column", overflowY: "auto", minHeight: 0, minWidth: 0 }}>
|
|
{/* Models stays mounted so active downloads survive page navigation */}
|
|
<div style={{
|
|
display: currentPage === "models" ? "flex" : "none",
|
|
flexDirection: "column",
|
|
flexGrow: 1,
|
|
minHeight: 0,
|
|
}}>
|
|
<Models onPullStateChange={setIsModelPulling} />
|
|
</div>
|
|
{/* Chatbot stays mounted so an in-flight reply survives page navigation */}
|
|
<div style={{
|
|
display: currentPage === "chatbot" ? "flex" : "none",
|
|
flexDirection: "column",
|
|
flexGrow: 1,
|
|
minHeight: 0,
|
|
}}>
|
|
<Chatbot
|
|
visible={currentPage === "chatbot"}
|
|
conversationId={activeConversationId}
|
|
setConversationId={setActiveConversationId}
|
|
onConversationChanged={() => loadConversations(search)}
|
|
/>
|
|
</div>
|
|
{currentPage === "playbook" && <Playbook />}
|
|
{currentPage === "memory" && <Memory />}
|
|
{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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|