forked from enderofwings/NexusOS
Initial commit: NexusOS - local AI assistant platform
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
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 { Logs } from "./Logs";
|
||||
|
||||
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 [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");
|
||||
});
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ key: "chatbot", label: "💬 Chat" },
|
||||
{ key: "playbook", label: "📖 Playbooks" },
|
||||
{ key: "models", label: "🤖 Models", badge: isModelPulling },
|
||||
{ key: "memory", label: "🧠 Memory" },
|
||||
{ key: "logs", label: "📜 Logs" },
|
||||
{ key: "settings", label: "⚙️ Settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ background: "#111", color: "#eee", height: "100vh", overflow: "hidden", fontFamily: "system-ui", display: "flex" }}>
|
||||
{/* Sidebar */}
|
||||
<aside style={{
|
||||
width: "260px",
|
||||
flexShrink: 0,
|
||||
background: "#0a0a0a",
|
||||
borderRight: "1px solid #333",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{/* Top cell: brand + nav */}
|
||||
<div style={{
|
||||
padding: "1.25rem 1rem 1rem",
|
||||
borderBottom: "1px solid #1f1f1f",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<img src="/n small.png" alt="Logo" style={{ width: "48px", height: "48px", objectFit: "contain", borderRadius: "8px" }} />
|
||||
<h1 style={{ fontSize: "1rem", margin: 0, color: "#007acc", textAlign: "center" }}>NexusOS</h1>
|
||||
{version && (
|
||||
<span style={{ fontSize: "0.7rem", color: "#666", marginTop: "-0.5rem", letterSpacing: "0.02em" }}>v{version}</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: "pointer",
|
||||
transition: "all 0.3s"
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
loadStatus();
|
||||
setShowStatusTooltip(true);
|
||||
}}
|
||||
onMouseLeave={() => setShowStatusTooltip(false)}
|
||||
title="Hover to refresh"
|
||||
/>
|
||||
{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" }}>
|
||||
Ollama: <strong>{ollamaStatus}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual AI start/stop — Ollama is off until the user turns it on */}
|
||||
<button
|
||||
onClick={toggleOllama}
|
||||
disabled={ollamaBusy || ollamaStatus === "unavailable" || status === "Offline"}
|
||||
title={ollamaStatus === "unavailable" ? "Ollama binary not found" : "Start or stop the AI"}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: ollamaStatus === "running" ? "#2a1a1a" : "#152a15",
|
||||
color: ollamaStatus === "running" ? "#ff8a80" : "#8aff8a",
|
||||
border: "1px solid " + (ollamaStatus === "running" ? "#5a2a2a" : "#2a5a2a"),
|
||||
borderRadius: "8px",
|
||||
cursor: (ollamaBusy || ollamaStatus === "unavailable" || status === "Offline") ? "not-allowed" : "pointer",
|
||||
fontSize: "0.8rem",
|
||||
opacity: (ollamaStatus === "unavailable" || status === "Offline") ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Naming the phase matters: nearly all of the wait is the model
|
||||
being read off disk (GBs), not the server booting. "…" for a
|
||||
minute reads as hung. */}
|
||||
{ollamaBusy ? (ollamaStatus === "running" ? "Stopping…" : "Loading model…")
|
||||
: ollamaStatus === "running" ? "⏹ Stop AI" : "▶ Start AI"}
|
||||
</button>
|
||||
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: "0.4rem", width: "100%", marginTop: "0.25rem" }}>
|
||||
{navItems.map(item => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.key)}
|
||||
style={{
|
||||
padding: "0.65rem 0.85rem",
|
||||
background: currentPage === item.key ? "#007acc" : "#161616",
|
||||
color: "#fff",
|
||||
border: "1px solid " + (currentPage === item.key ? "#0099ff" : "#2a2a2a"),
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
textAlign: "left",
|
||||
transition: "all 0.15s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{item.badge && (
|
||||
<span style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
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.75rem 0.75rem 0.75rem",
|
||||
}}>
|
||||
<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>
|
||||
<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>
|
||||
<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: isActive ? "#1c2a3a" : (isHover ? "#161616" : "transparent"),
|
||||
border: `1px solid ${isActive ? "#2a4a6a" : "transparent"}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: "0.82rem",
|
||||
color: isActive ? "#eee" : "#ccc",
|
||||
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) => 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>
|
||||
{currentPage === "chatbot" && (
|
||||
<Chatbot
|
||||
conversationId={activeConversationId}
|
||||
setConversationId={setActiveConversationId}
|
||||
onConversationChanged={() => loadConversations(search)}
|
||||
/>
|
||||
)}
|
||||
{currentPage === "playbook" && <Playbook />}
|
||||
{currentPage === "memory" && <Memory />}
|
||||
{currentPage === "logs" && <Logs />}
|
||||
{currentPage === "settings" && <Settings />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user