diff --git a/frontend/src/App.js b/frontend/src/App.js
index b4521aa..b02cf78 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -1,14 +1,14 @@
// App.js
import React, { useState, useEffect } from "react";
import { Navbar } from "./shared/components";
-import HomePage from "./pages/HomePage";
+import { HomePage } from "./features/chat";
import { VoiceChatPage } from "./features/voice";
-import SwarmControlPage from "./pages/SwarmControlPage";
-import LoginPage from "./pages/LoginPage";
-import SettingsPage from "./pages/SettingsPage";
-import ProfilePage from "./pages/ProfilePage";
+import { SwarmControlPage } from "./features/swarm";
+import { LoginPage } from "./features/auth";
+import { SettingsPage } from "./features/settings";
+import { ProfilePage } from "./features/profile";
import { NodesPage } from "./features/nodes";
-import SkillsPage from "./pages/SkillsPage";
+import { SkillsPage } from "./features/skills";
import { getUserStatus, logout, getUserProfile } from "./services/apiService";
const Icon = ({ path, onClick, className }) => (
diff --git a/frontend/src/features/auth/index.js b/frontend/src/features/auth/index.js
new file mode 100644
index 0000000..9eeae5a
--- /dev/null
+++ b/frontend/src/features/auth/index.js
@@ -0,0 +1 @@
+export { default as LoginPage } from './pages/LoginPage';
diff --git a/frontend/src/features/auth/pages/LoginPage.js b/frontend/src/features/auth/pages/LoginPage.js
new file mode 100644
index 0000000..1e53c14
--- /dev/null
+++ b/frontend/src/features/auth/pages/LoginPage.js
@@ -0,0 +1,126 @@
+import React, { useState, useEffect } from 'react';
+import { login, getUserStatus, logout } from '../../../services/apiService';
+
+const LoginPage = () => {
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // We now look for a 'user_id' in the URL, which is provided by the backend
+ // after a successful OIDC login and callback.
+ const params = new URLSearchParams(window.location.search);
+ const userIdFromUrl = params.get('user_id');
+
+ // First, check localStorage for a saved user ID for persistent login
+ const storedUserId = localStorage.getItem('userId');
+ const userId = userIdFromUrl || storedUserId;
+
+ if (userId) {
+ setIsLoading(true);
+ // Fetch the full user details using the user ID from the URL.
+ // This is a more secure and robust way to handle the final callback.
+ const fetchUserDetails = async () => {
+ try {
+ const userStatus = await getUserStatus(userId);
+ setUser(userStatus);
+ // Store the user ID for future requests (e.g., in localStorage)
+ localStorage.setItem('userId', userStatus.id);
+ // Clean up the URL by removing the query parameter
+ window.history.replaceState({}, document.title, window.location.pathname);
+ } catch (err) {
+ setError('Failed to get user status. Please try again.');
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchUserDetails();
+ }
+ }, []);
+
+ const handleLogin = () => {
+ // Redirect to the backend's /users/login endpoint
+ // The backend handles the OIDC redirect from there.
+ login();
+ };
+
+ const handleLogout = async () => {
+ setIsLoading(true);
+ try {
+ await logout();
+ localStorage.removeItem('userId');
+ setUser(null);
+ setError(null);
+ } catch (err) {
+ setError('Failed to log out. Please try again.');
+ console.error(err);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const renderContent = () => {
+ if (isLoading) {
+ return (
+
+ Click the button below to log in using OpenID Connect (OIDC).
+
+ setShowFileExplorer(false)}
+ />
+
+
+
File Explorer
+
setShowFileExplorer(false)} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md">
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/features/swarm/hooks/useSwarmControl.js b/frontend/src/features/swarm/hooks/useSwarmControl.js
new file mode 100644
index 0000000..648b1fe
--- /dev/null
+++ b/frontend/src/features/swarm/hooks/useSwarmControl.js
@@ -0,0 +1,244 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { getSessionId } from "../../../services/websocket";
+import { getSessionTokenStatus, getSessionMessages, chatWithAI, getUserConfig, getSession, cancelSession } from "../../../services/apiService";
+
+const useSwarmControl = ({ pageContainerRef, onNewSessionCreated }) => {
+ const [chatHistory, setChatHistory] = useState([]);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+ const [showErrorModal, setShowErrorModal] = useState(false);
+ const [isConfigured, setIsConfigured] = useState(true);
+ const [missingConfigs, setMissingConfigs] = useState([]);
+ const [sessionId, setSessionId] = useState(null);
+ const [tokenUsage, setTokenUsage] = useState({ token_count: 0, token_limit: 0, percentage: 0 });
+ const [userConfigData, setUserConfigData] = useState(null);
+ const [localActiveLLM, setLocalActiveLLM] = useState('');
+
+ const sessionIdRef = useRef(null);
+ const initialized = useRef(false);
+
+ const fetchTokenUsage = useCallback(async () => {
+ if (!sessionIdRef.current) return;
+ try {
+ const usage = await getSessionTokenStatus(sessionIdRef.current);
+ setTokenUsage(usage);
+ } catch (err) {
+ console.warn("Failed to fetch token usage", err);
+ }
+ }, []);
+
+ const fetchSessionHistory = useCallback(async (sid) => {
+ try {
+ const messagesData = await getSessionMessages(sid);
+ if (messagesData && messagesData.messages) {
+ const formattedHistory = messagesData.messages.map((msg) => ({
+ isUser: msg.sender === "user",
+ isPureAnswer: true,
+ text: msg.content,
+ reasoning: msg.reasoning_content,
+ timestamp: msg.created_at
+ }));
+ setChatHistory(formattedHistory);
+ }
+ } catch (err) {
+ console.warn("Failed to load chat history", err);
+ }
+ }, []);
+
+ // Setup
+ useEffect(() => {
+ if (initialized.current) return;
+ initialized.current = true;
+
+ const setup = async () => {
+ try {
+ let configData = null;
+ let provider = "gemini";
+ try {
+ configData = await getUserConfig();
+ setUserConfigData(configData);
+ if (configData.effective?.llm?.active_provider) {
+ provider = configData.effective.llm.active_provider;
+ }
+ } catch (e) {
+ console.warn("Could not load user config", e);
+ }
+
+ let wasNewSession = false;
+ if (!localStorage.getItem("sessionId_swarm_control")) {
+ wasNewSession = true;
+ }
+ const sid = await getSessionId("swarm_control", provider);
+ setSessionId(sid);
+ sessionIdRef.current = sid;
+ if (wasNewSession && onNewSessionCreated) {
+ onNewSessionCreated(sid);
+ }
+
+ let llm = provider;
+ try {
+ const sessionInfo = await getSession(sid);
+ if (sessionInfo && sessionInfo.provider_name) {
+ llm = sessionInfo.provider_name;
+ }
+ } catch (e) { console.warn("Could not check session provider", e); }
+ setLocalActiveLLM(llm);
+
+ // Config check
+ const eff = configData?.effective || {};
+ const missing = [];
+ const llmProviders = eff.llm?.providers || {};
+ const hasLLMKey = Object.values(llmProviders).some(p => p.api_key && p.api_key !== 'None');
+ if (!hasLLMKey) missing.push("Language Model (LLM) API Key");
+
+ if (missing.length > 0) {
+ setIsConfigured(false);
+ setMissingConfigs(missing);
+ } else {
+ setIsConfigured(true);
+ setMissingConfigs([]);
+ }
+
+ await fetchSessionHistory(sid);
+ await fetchTokenUsage();
+ } catch (error) {
+ console.error("Setup failed:", error);
+ }
+ };
+
+ setup();
+ }, [fetchSessionHistory, fetchTokenUsage]);
+
+ const handleSendChat = useCallback(async (text) => {
+ if (!isConfigured && text.trim().toLowerCase() !== "/new") {
+ setErrorMessage("Swarm Control requires a valid LLM configuration. Please visit Settings to set up your API keys.");
+ setShowErrorModal(true);
+ return;
+ }
+
+ if (text.trim().toLowerCase() === "/new") {
+ setChatHistory([]);
+ localStorage.removeItem("sessionId_swarm_control");
+ const prefProvider = userConfigData?.effective?.llm?.active_provider || "gemini";
+ const newSid = await getSessionId("swarm_control", prefProvider);
+ if (onNewSessionCreated) {
+ onNewSessionCreated(newSid);
+ }
+ setLocalActiveLLM(prefProvider);
+ setSessionId(newSid);
+ sessionIdRef.current = newSid;
+ fetchTokenUsage();
+ return;
+ }
+
+ setIsProcessing(true);
+ setChatHistory((prev) => [...prev, { isUser: true, text, timestamp: new Date().toISOString() }]);
+
+ try {
+ let reasoningStartTime = null;
+ let reasoningDuration = 0;
+
+ // Add a placeholder message for the AI starting with 'Generating' status
+ setChatHistory((prev) => [...prev, {
+ isUser: false,
+ text: "",
+ reasoning: "",
+ status: "Generating", // Initially show 'Generating'
+ provider: localActiveLLM,
+ timestamp: new Date().toISOString()
+ }]);
+
+ await chatWithAI(sessionIdRef.current, text, localActiveLLM || "gemini", (event) => {
+ setChatHistory((prev) => {
+ const newHistory = [...prev];
+ const lastMsg = { ...newHistory[newHistory.length - 1] };
+
+ if (event.type === "reasoning") {
+ if (!reasoningStartTime) reasoningStartTime = Date.now();
+ lastMsg.reasoning += event.content;
+ // Only update status to planning if we are in the brain's strategy phase.
+ if (!lastMsg.status || (lastMsg.status === "Generating" || lastMsg.status === "Analyzing & Planning")) {
+ lastMsg.status = "Analyzing & Planning";
+ }
+ } else if (event.type === "content") {
+ if (reasoningStartTime && !lastMsg.thoughtDone) {
+ reasoningDuration = Math.round((Date.now() - reasoningStartTime) / 1000);
+ lastMsg.status = reasoningDuration > 0 ? `Thought for ${reasoningDuration}s` : null;
+ lastMsg.thoughtDone = true;
+ } else if (!reasoningStartTime && (lastMsg.status === "Generating" || lastMsg.status === "Analyzing & Planning")) {
+ lastMsg.status = null;
+ }
+ lastMsg.text += event.content;
+ } else if (event.type === "status") {
+ lastMsg.status = event.content;
+ } else if (event.type === "tool_start") {
+ lastMsg.status = `Calling tool: ${event.name}`;
+ } else if (event.type === "tool_result") {
+ lastMsg.status = `Tool ${event.name} returned.`;
+ }
+
+ newHistory[newHistory.length - 1] = lastMsg;
+ return newHistory;
+ });
+ });
+
+ fetchTokenUsage();
+ } catch (error) {
+ setErrorMessage(error.message);
+ setShowErrorModal(true);
+ } finally {
+ setIsProcessing(false);
+ }
+
+
+ }, [isConfigured, localActiveLLM, fetchTokenUsage]);
+ const handleCancelChat = useCallback(async () => {
+ if (!sessionIdRef.current) return;
+ try {
+ await cancelSession(sessionIdRef.current);
+ // We don't set isProcessing false here immediately if we want to wait for the stream
+ // to actually close, but the user wants immediate feedback.
+ // RagPipeline already checks session.is_cancelled at the start of each turn.
+ } catch (err) {
+ console.warn("Failed to cancel session", err);
+ }
+ }, []);
+
+ const handleSwitchSession = useCallback(async (targetSessionId) => {
+ localStorage.setItem("sessionId_swarm_control", targetSessionId);
+ setSessionId(targetSessionId);
+ sessionIdRef.current = targetSessionId;
+ setChatHistory([]);
+
+ try {
+ const sessionInfo = await getSession(targetSessionId);
+ if (sessionInfo && sessionInfo.provider_name) {
+ setLocalActiveLLM(sessionInfo.provider_name);
+ }
+ await fetchSessionHistory(targetSessionId);
+ await fetchTokenUsage();
+ } catch (error) {
+ console.error("Failed to switch session:", error);
+ }
+ }, [fetchSessionHistory, fetchTokenUsage]);
+
+ return {
+ chatHistory,
+ isProcessing,
+ errorMessage,
+ showErrorModal,
+ tokenUsage,
+ isConfigured,
+ missingConfigs,
+ handleSendChat,
+ handleCancelChat,
+ setShowErrorModal,
+ handleSwitchSession,
+ sessionId,
+ userConfigData,
+ localActiveLLM,
+ setLocalActiveLLM
+ };
+};
+
+export default useSwarmControl;
diff --git a/frontend/src/features/swarm/index.js b/frontend/src/features/swarm/index.js
new file mode 100644
index 0000000..aba07e0
--- /dev/null
+++ b/frontend/src/features/swarm/index.js
@@ -0,0 +1,4 @@
+// Feature entry point for the Swarm Control feature.
+
+export { default as SwarmControlPage } from "./pages/SwarmControlPage";
+export { default as useSwarmControl } from "./hooks/useSwarmControl";
diff --git a/frontend/src/features/swarm/pages/SwarmControlPage.js b/frontend/src/features/swarm/pages/SwarmControlPage.js
new file mode 100644
index 0000000..8d01787
--- /dev/null
+++ b/frontend/src/features/swarm/pages/SwarmControlPage.js
@@ -0,0 +1,717 @@
+import React, { useState, useRef, useEffect, useCallback } from "react";
+import { ChatArea } from "../../chat";
+import { SessionSidebar } from "../../../shared/components";
+import useSwarmControl from "../hooks/useSwarmControl";
+import {
+ updateSession, getSessionNodeStatus, attachNodesToSession,
+ detachNodeFromSession, getUserAccessibleNodes, getUserNodePreferences, nodeFsList,
+ clearSessionHistory
+} from "../../../services/apiService";
+import {
+ SwarmControlConsoleOverlay,
+ SwarmControlFileExplorerOverlay
+} from "../components/SwarmControlOverlays";
+import { SwarmControlNodeSelectorModal } from "../components/SwarmControlNodeSelectorModal";
+
+const CodeAssistantPage = () => {
+ const pageContainerRef = useRef(null);
+
+ const onNewSessionCreated = useCallback(async (newSid) => {
+ try {
+ const [accessibleNodes, prefs] = await Promise.all([
+ getUserAccessibleNodes(),
+ getUserNodePreferences()
+ ]);
+
+ let targetIds = [];
+ const defaultIds = prefs.default_node_ids || [];
+
+ if (defaultIds.length > 0) {
+ // Filter defaults by what is actually accessible
+ const accessibleIds = accessibleNodes.map(n => n.node_id);
+ targetIds = defaultIds.filter(id => accessibleIds.includes(id));
+ } else {
+ // Fallback: attach all accessible nodes if no defaults specified
+ targetIds = accessibleNodes.map(n => n.node_id);
+ }
+
+ if (targetIds.length > 0) {
+ const syncConfig = prefs.data_source || { source: 'server' };
+ await attachNodesToSession(newSid, targetIds, syncConfig);
+
+ // Immediate local sync for UI
+ setAttachedNodeIds(targetIds);
+ setSyncConfig(syncConfig); // Ensure UI matches the applied config
+
+ // Refresh full status from server to stay in sync
+ const status = await getSessionNodeStatus(newSid);
+ const apiIds = (status.nodes || []).map(n => n.node_id);
+ setAttachedNodeIds(apiIds);
+ }
+ } catch (e) {
+ console.warn("M3: Failed auto attaching defaults on new session", e);
+ }
+ }, []);
+
+ const {
+ chatHistory,
+ isProcessing,
+ errorMessage,
+ showErrorModal,
+ tokenUsage,
+ handleSendChat,
+ handleCancelChat,
+ setShowErrorModal,
+ handleSwitchSession,
+ sessionId,
+ userConfigData,
+ localActiveLLM,
+ setLocalActiveLLM,
+ isConfigured,
+ missingConfigs
+ } = useSwarmControl({ pageContainerRef, onNewSessionCreated });
+
+ const [showConfigModal, setShowConfigModal] = useState(false);
+ const [showClearChatModal, setShowClearChatModal] = useState(false);
+ const [isClearingHistory, setIsClearingHistory] = useState(false);
+
+ const confirmClearHistory = async () => {
+ if (!sessionId) return;
+ setIsClearingHistory(true);
+ try {
+ await clearSessionHistory(sessionId);
+ // Reload the page to refresh chat history from the server
+ window.location.reload();
+ } catch (e) {
+ alert(`Failed to clear history: ${e.message}`);
+ } finally {
+ setIsClearingHistory(false);
+ setShowClearChatModal(false);
+ }
+ };
+
+ const handleClearHistory = () => {
+ if (!sessionId) return;
+ setShowClearChatModal(true);
+ };
+
+ const [showNodeSelector, setShowNodeSelector] = useState(false);
+ const isEditingMeshRef = useRef(false);
+ useEffect(() => {
+ isEditingMeshRef.current = showNodeSelector;
+ }, [showNodeSelector]);
+ const [sidebarRefreshTick, setSidebarRefreshTick] = useState(0);
+
+ // M3/M6 Node Integration State
+ const [sessionNodeStatus, setSessionNodeStatus] = useState({}); // node_id -> { status, last_sync }
+ const [accessibleNodes, setAccessibleNodes] = useState([]);
+ const [attachedNodeIds, setAttachedNodeIds] = useState([]);
+ const [workspaceId, setWorkspaceId] = useState("");
+ const [showConsole, setShowConsole] = useState(false);
+ const [syncConfig, setSyncConfig] = useState({ source: 'server', path: '', source_node_id: '', read_only_node_ids: [] });
+ const [activeSyncConfig, setActiveSyncConfig] = useState(null);
+ const [pathSuggestions, setPathSuggestions] = useState([]);
+ const [isSearchingPath, setIsSearchingPath] = useState(false);
+ const [showPathSuggestions, setShowPathSuggestions] = useState(false);
+ const [hasLoadedDefaults, setHasLoadedDefaults] = useState(false);
+ const [isInitiatingSync, setIsInitiatingSync] = useState(false);
+ const [showFileExplorer, setShowFileExplorer] = useState(false);
+ const [isConsoleExpanded, setIsConsoleExpanded] = useState(false);
+ const [consoleHeight, setConsoleHeight] = useState(256); // Default 64 * 4px = 256px
+ const [isDraggingConsole, setIsDraggingConsole] = useState(false);
+ const isDraggingConsoleRef = useRef(false);
+
+ // Persistence for Auto-Collapse
+ const [autoCollapse, setAutoCollapse] = useState(() => {
+ return localStorage.getItem("swarm_auto_collapse") === "true";
+ });
+
+ const toggleAutoCollapse = () => {
+ const newState = !autoCollapse;
+ setAutoCollapse(newState);
+ localStorage.setItem("swarm_auto_collapse", newState);
+ };
+
+ // Handle Dragging Console Resizer explicitly
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!isDraggingConsoleRef.current) return;
+ e.preventDefault();
+ const newHeight = window.innerHeight - e.clientY;
+ const clampedHeight = Math.max(100, Math.min(window.innerHeight * 0.9, newHeight));
+ setConsoleHeight(clampedHeight);
+ };
+
+ const handleMouseUp = () => {
+ if (isDraggingConsoleRef.current) {
+ isDraggingConsoleRef.current = false;
+ setIsDraggingConsole(false);
+ document.body.style.cursor = 'default';
+ // Auto-fit xterm when dragged
+ window.dispatchEvent(new Event('resize'));
+ }
+ };
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, []);
+
+ const handleConsoleDragStart = (e) => {
+ e.preventDefault(); // Prevents text selection while dragging
+ e.stopPropagation();
+ isDraggingConsoleRef.current = true;
+ setIsDraggingConsole(true);
+ document.body.style.cursor = 'row-resize';
+ };
+
+ // M6: Persistence - if we have an active config, populate the form with it when modal opens
+ useEffect(() => {
+ if (showNodeSelector && activeSyncConfig) {
+ setSyncConfig({
+ source: activeSyncConfig.source || 'server',
+ path: activeSyncConfig.path || '',
+ source_node_id: activeSyncConfig.source_node_id || '',
+ read_only_node_ids: activeSyncConfig.read_only_node_ids || []
+ });
+ }
+ }, [showNodeSelector, activeSyncConfig]);
+
+ // Auto-enforce Receiver Only logic in UI
+ useEffect(() => {
+ if (syncConfig.source === 'server') {
+ setSyncConfig(prev => ({ ...prev, read_only_node_ids: [...attachedNodeIds] }));
+ } else if (syncConfig.source === 'node_local' && syncConfig.source_node_id) {
+ const others = attachedNodeIds.filter(id => id !== syncConfig.source_node_id);
+ setSyncConfig(prev => ({ ...prev, read_only_node_ids: others }));
+ }
+ }, [syncConfig.source, syncConfig.source_node_id, attachedNodeIds]);
+
+ const handleInitiateSync = async () => {
+ if (!sessionId) return;
+ setIsInitiatingSync(true);
+ try {
+ await attachNodesToSession(sessionId, attachedNodeIds, syncConfig);
+ // M3: Explicitly turn off the editing mesh ref and modal BEFORE fetching
+ // so the server's newly saved attached nodes replace the user checkboxes immediately
+ isEditingMeshRef.current = false;
+ setShowNodeSelector(false);
+
+ await fetchNodeInfo();
+ } catch (err) {
+ alert(`Sync Error: ${err.message}`);
+ } finally {
+ setIsInitiatingSync(false);
+ }
+ };
+
+ const fetchNodeInfo = async () => {
+ if (!sessionId) return;
+ try {
+ const [status, nodes] = await Promise.all([
+ getSessionNodeStatus(sessionId),
+ getUserAccessibleNodes()
+ ]);
+ const apiNodes = status.nodes || [];
+ const apiIds = apiNodes.map(n => n.node_id);
+
+ // Sanitized ID List: only show IDs that are actually in the live accessible list
+ const liveIds = nodes.map(n => n.node_id);
+ const sanitizedIds = apiIds.filter(id => liveIds.includes(id));
+
+ const syncStatusMap = {};
+ apiNodes.forEach(n => {
+ syncStatusMap[n.node_id] = { status: n.status, last_sync: n.last_sync };
+ });
+
+ setSessionNodeStatus(syncStatusMap);
+ setWorkspaceId(status.sync_workspace_id || "");
+ setAccessibleNodes(nodes);
+
+ // Stop auto-poll from wiping out user's active checkbox edits
+ if (!isEditingMeshRef.current) {
+ setAttachedNodeIds(sanitizedIds);
+ setActiveSyncConfig(status.sync_config || null);
+ }
+ } catch (e) {
+ console.warn("M3: Failed to fetch session node info", e);
+ }
+ };
+
+ useEffect(() => {
+ fetchNodeInfo();
+ const interval = setInterval(fetchNodeInfo, 5000); // Polling status
+ return () => clearInterval(interval);
+ }, [sessionId]);
+
+ // M3: Path Autocomplete Logic
+ useEffect(() => {
+ const fetchSuggestions = async () => {
+ if (syncConfig.source !== 'node_local' || !syncConfig.source_node_id || !syncConfig.path) {
+ setPathSuggestions([]);
+ return;
+ }
+
+ const path = syncConfig.path;
+ // Determine the directory to list and the fragment being typed
+ let dirToList = ".";
+ let fragment = "";
+
+ if (path.includes('/')) {
+ const lastSlashIndex = path.lastIndexOf('/');
+ dirToList = path.substring(0, lastSlashIndex) || "/";
+ fragment = path.substring(lastSlashIndex + 1);
+ } else {
+ fragment = path;
+ }
+
+ setIsSearchingPath(true);
+ try {
+ const results = await nodeFsList(syncConfig.source_node_id, dirToList);
+ const fileList = results.files || [];
+
+ // Filter: must be directory AND must start with the current fragment
+ const dirs = fileList
+ .filter(item => item.is_dir && item.name.toLowerCase().startsWith(fragment.toLowerCase()))
+ .map(item => item.name);
+
+ setPathSuggestions(dirs);
+ } catch (e) {
+ setPathSuggestions([]);
+ } finally {
+ setIsSearchingPath(false);
+ }
+ };
+
+ const timer = setTimeout(fetchSuggestions, 500);
+ return () => clearTimeout(timer);
+ }, [syncConfig.path, syncConfig.source_node_id, syncConfig.source]);
+
+ useEffect(() => {
+ const loadDefaults = async () => {
+ try {
+ const prefs = await getUserNodePreferences();
+ if (prefs && prefs.data_source && !hasLoadedDefaults) {
+ setSyncConfig(prefs.data_source);
+ setHasLoadedDefaults(true);
+ }
+ } catch (e) {
+ console.warn("M3: Failed to load node defaults", e);
+ }
+ };
+ loadDefaults();
+ }, [hasLoadedDefaults]);
+
+ const handleToggleNode = (nodeId, isCurrentlyAttached) => {
+ if (isCurrentlyAttached) {
+ setAttachedNodeIds(prev => prev.filter(id => id !== nodeId));
+ // Also remove from read-only if it's there
+ setSyncConfig(prev => ({
+ ...prev,
+ read_only_node_ids: (prev.read_only_node_ids || []).filter(id => id !== nodeId)
+ }));
+ } else {
+ setAttachedNodeIds(prev => [...prev, nodeId]);
+ }
+ };
+
+ const handleToggleReadOnly = (nodeId) => {
+ setSyncConfig(prev => {
+ const current = prev.read_only_node_ids || [];
+ if (current.includes(nodeId)) {
+ return { ...prev, read_only_node_ids: current.filter(id => id !== nodeId) };
+ } else {
+ return { ...prev, read_only_node_ids: [...current, nodeId] };
+ }
+ });
+ };
+
+ const handleSaveQuickConfig = async () => {
+ try {
+ if (sessionId && localActiveLLM) {
+ await updateSession(sessionId, { provider_name: localActiveLLM });
+ setSidebarRefreshTick(t => t + 1);
+ }
+ setShowConfigModal(false);
+ } catch (e) {
+ console.error("Failed to update session configs:", e);
+ }
+ };
+
+ useEffect(() => {
+ if (pageContainerRef.current) {
+ pageContainerRef.current.scrollTop = pageContainerRef.current.scrollHeight;
+ }
+ }, [chatHistory]);
+
+
+ return (
+
+ {/* Invisible overlay to catch events across the entire screen during fast drag */}
+ {isDraggingConsole && (
+
+ )}
+
+
handleSendChat("/new")}
+ refreshTick={sidebarRefreshTick}
+ />
+
+ {/* Main content area */}
+
+
+ {/* Chat Area & Header */}
+
+
+
+
+
+
+ Swarm Control
+
+ Mesh: {accessibleNodes.filter(n => n.last_status === 'online' || n.last_status === 'idle').length} Online / {accessibleNodes.length} Total
+
+
+
+ {/* Nodes Indicator Bar (M3/M6) */}
+
+ {attachedNodeIds.length === 0 ? (
+
setShowNodeSelector(true)}
+ >
+ {accessibleNodes.length === 0 ? 'No nodes found in mesh' : 'Click to attach nodes'}
+
+ ) : (
+
setShowNodeSelector(true)}>
+
+ {accessibleNodes.filter(n => attachedNodeIds.includes(n.node_id)).slice(0, 3).map((node, i) => {
+ const isOnline = node.last_status === 'online' || node.last_status === 'idle' || node.last_status === 'busy';
+ return (
+
+ {node.display_name.charAt(0).toUpperCase()}
+
+ )
+ })}
+ {attachedNodeIds.length > 3 && (
+
+ +{attachedNodeIds.length - 3}
+
+ )}
+
+
+
+ {attachedNodeIds.length} Attached
+
+
+ {(workspaceId || syncConfig.source !== 'empty') && (
+
+
+
+ File Sync Active
+
+ Workspace: {workspaceId || 'Initializing...'}
+
+
+
+ )}
+
+
+ Click to manage mesh strategy
+
+
+ )}
+ {attachedNodeIds.length > 0 && (
+
+
setShowConsole(!showConsole)}
+ className={`p-1.5 rounded-lg transition-colors ${showConsole ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-50'}`}
+ title="Toggle Execution Console"
+ >
+
+
+
+
+ {workspaceId && (
+
setShowFileExplorer(!showFileExplorer)}
+ className={`p-1.5 rounded-lg transition-colors ${showFileExplorer ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-50'}`}
+ title="Toggle File Explorer"
+ >
+
+
+
+
+ )}
+
+ )}
+
+
+ {!isConfigured && (
+
+
+
+
+
+
Missing Key
+
+ {missingConfigs?.map((m, i) => {m} )}
+
+
+
+ )}
+
setShowConfigModal(true)}
+ className="text-gray-400 hover:text-indigo-600 transition-colors"
+ >
+
+
+
+
+
+
+
+
+ Auto-Collapse
+
+
+ Conversational focus mode
+ Collapses previous AI steps when finished
+
+
+
+
+
+ Token Usage
+
+
+
+
80 ? 'bg-red-500' : 'bg-indigo-500'}`}
+ style={{ width: `${Math.min(tokenUsage?.percentage || 0, 100)}%` }}
+ >
+
+
80 ? 'text-red-500' : 'text-gray-400'}`}>
+ {tokenUsage?.percentage || 0}%
+
+
+
+
+
+
+ {isClearingHistory ? '...' : 'Clear Chat'}
+
+
+ Clear Chat History
+ Nodes & workspace sync are preserved
+
+
+
handleSendChat("/new")}
+ className="text-xs font-bold px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-all shadow-md hover:shadow-indigo-500/20 active:scale-95"
+ >
+ + NEW
+
+
+
+
+
+ {
+ const sourceNode = accessibleNodes.find(n => n.node_id === activeSyncConfig.source_node_id);
+ return !sourceNode || (sourceNode.last_status !== 'online' && sourceNode.last_status !== 'idle');
+ })()
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ setShowNodeSelector(false)}
+ onCancel={() => {
+ setShowNodeSelector(false);
+ isEditingMeshRef.current = false;
+ fetchNodeInfo();
+ }}
+ accessibleNodes={accessibleNodes}
+ attachedNodeIds={attachedNodeIds}
+ syncConfig={syncConfig}
+ setSyncConfig={setSyncConfig}
+ workspaceId={workspaceId}
+ isSearchingPath={isSearchingPath}
+ showPathSuggestions={showPathSuggestions}
+ setShowPathSuggestions={setShowPathSuggestions}
+ pathSuggestions={pathSuggestions}
+ setPathSuggestions={setPathSuggestions}
+ handleInitiateSync={handleInitiateSync}
+ isInitiatingSync={isInitiatingSync}
+ handleToggleNode={handleToggleNode}
+ handleToggleReadOnly={handleToggleReadOnly}
+ />
+
+ {/* Error Modal */}
+ {showErrorModal && (
+
+
+
+
Attention Required
+
{errorMessage}
+
setShowErrorModal(false)}
+ className="w-full bg-gray-900 dark:bg-white dark:text-gray-900 text-white font-bold py-3 rounded-xl transition-all active:scale-95"
+ >
+ Understand
+
+
+
+ )}
+
+ {showConfigModal && (
+
+
+
+
+
+ Session Engine
+
+
setShowConfigModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2">
+
+
+
+
+
+ Active LLM Provider
+ setLocalActiveLLM(e.target.value)}
+ className="w-full border-2 border-gray-100 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-800 text-sm font-medium focus:border-indigo-500 focus:ring-0 transition-all outline-none"
+ >
+ -- Choose Provider --
+ {userConfigData?.effective?.llm?.providers && Object.keys(userConfigData.effective.llm.providers).map(pid => {
+ const modelName = userConfigData.effective.llm.providers[pid].model;
+ return (
+
+ {pid} {modelName ? `(${modelName})` : ''}
+
+ );
+ })}
+
+
+
+
+ setShowConfigModal(false)}
+ className="px-6 py-2.5 text-sm font-bold text-gray-500 hover:text-gray-800 transition-colors"
+ >
+ Cancel
+
+
+ Apply Changes
+
+
+
+
+ )}
+
+ {/* Clear Chat Confirmation Modal */}
+ {showClearChatModal && (
+
+
+
+
Clear Chat History?
+
+ This will permanently delete all messages in this session. Your attached nodes, workspace files, and mesh configurations will be preserved .
+
+
+
setShowClearChatModal(false)}
+ className="flex-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white font-bold py-3 rounded-xl transition-all active:scale-95"
+ >
+ Cancel
+
+
+ {isClearingHistory ? (
+ <>
+
+ Clearing...
+ >
+ ) : 'Clear Chat'}
+
+
+
+
+ )}
+
+ );
+};
+
+export default CodeAssistantPage;
\ No newline at end of file
diff --git a/frontend/src/hooks/useSwarmControl.js b/frontend/src/hooks/useSwarmControl.js
deleted file mode 100644
index 8ea32c2..0000000
--- a/frontend/src/hooks/useSwarmControl.js
+++ /dev/null
@@ -1,244 +0,0 @@
-import { useState, useEffect, useRef, useCallback } from "react";
-import { getSessionId } from "../services/websocket";
-import { getSessionTokenStatus, getSessionMessages, chatWithAI, getUserConfig, getSession, cancelSession } from "../services/apiService";
-
-const useSwarmControl = ({ pageContainerRef, onNewSessionCreated }) => {
- const [chatHistory, setChatHistory] = useState([]);
- const [isProcessing, setIsProcessing] = useState(false);
- const [errorMessage, setErrorMessage] = useState("");
- const [showErrorModal, setShowErrorModal] = useState(false);
- const [isConfigured, setIsConfigured] = useState(true);
- const [missingConfigs, setMissingConfigs] = useState([]);
- const [sessionId, setSessionId] = useState(null);
- const [tokenUsage, setTokenUsage] = useState({ token_count: 0, token_limit: 0, percentage: 0 });
- const [userConfigData, setUserConfigData] = useState(null);
- const [localActiveLLM, setLocalActiveLLM] = useState('');
-
- const sessionIdRef = useRef(null);
- const initialized = useRef(false);
-
- const fetchTokenUsage = useCallback(async () => {
- if (!sessionIdRef.current) return;
- try {
- const usage = await getSessionTokenStatus(sessionIdRef.current);
- setTokenUsage(usage);
- } catch (err) {
- console.warn("Failed to fetch token usage", err);
- }
- }, []);
-
- const fetchSessionHistory = useCallback(async (sid) => {
- try {
- const messagesData = await getSessionMessages(sid);
- if (messagesData && messagesData.messages) {
- const formattedHistory = messagesData.messages.map((msg) => ({
- isUser: msg.sender === "user",
- isPureAnswer: true,
- text: msg.content,
- reasoning: msg.reasoning_content,
- timestamp: msg.created_at
- }));
- setChatHistory(formattedHistory);
- }
- } catch (err) {
- console.warn("Failed to load chat history", err);
- }
- }, []);
-
- // Setup
- useEffect(() => {
- if (initialized.current) return;
- initialized.current = true;
-
- const setup = async () => {
- try {
- let configData = null;
- let provider = "gemini";
- try {
- configData = await getUserConfig();
- setUserConfigData(configData);
- if (configData.effective?.llm?.active_provider) {
- provider = configData.effective.llm.active_provider;
- }
- } catch (e) {
- console.warn("Could not load user config", e);
- }
-
- let wasNewSession = false;
- if (!localStorage.getItem("sessionId_swarm_control")) {
- wasNewSession = true;
- }
- const sid = await getSessionId("swarm_control", provider);
- setSessionId(sid);
- sessionIdRef.current = sid;
- if (wasNewSession && onNewSessionCreated) {
- onNewSessionCreated(sid);
- }
-
- let llm = provider;
- try {
- const sessionInfo = await getSession(sid);
- if (sessionInfo && sessionInfo.provider_name) {
- llm = sessionInfo.provider_name;
- }
- } catch (e) { console.warn("Could not check session provider", e); }
- setLocalActiveLLM(llm);
-
- // Config check
- const eff = configData?.effective || {};
- const missing = [];
- const llmProviders = eff.llm?.providers || {};
- const hasLLMKey = Object.values(llmProviders).some(p => p.api_key && p.api_key !== 'None');
- if (!hasLLMKey) missing.push("Language Model (LLM) API Key");
-
- if (missing.length > 0) {
- setIsConfigured(false);
- setMissingConfigs(missing);
- } else {
- setIsConfigured(true);
- setMissingConfigs([]);
- }
-
- await fetchSessionHistory(sid);
- await fetchTokenUsage();
- } catch (error) {
- console.error("Setup failed:", error);
- }
- };
-
- setup();
- }, [fetchSessionHistory, fetchTokenUsage]);
-
- const handleSendChat = useCallback(async (text) => {
- if (!isConfigured && text.trim().toLowerCase() !== "/new") {
- setErrorMessage("Swarm Control requires a valid LLM configuration. Please visit Settings to set up your API keys.");
- setShowErrorModal(true);
- return;
- }
-
- if (text.trim().toLowerCase() === "/new") {
- setChatHistory([]);
- localStorage.removeItem("sessionId_swarm_control");
- const prefProvider = userConfigData?.effective?.llm?.active_provider || "gemini";
- const newSid = await getSessionId("swarm_control", prefProvider);
- if (onNewSessionCreated) {
- onNewSessionCreated(newSid);
- }
- setLocalActiveLLM(prefProvider);
- setSessionId(newSid);
- sessionIdRef.current = newSid;
- fetchTokenUsage();
- return;
- }
-
- setIsProcessing(true);
- setChatHistory((prev) => [...prev, { isUser: true, text, timestamp: new Date().toISOString() }]);
-
- try {
- let reasoningStartTime = null;
- let reasoningDuration = 0;
-
- // Add a placeholder message for the AI starting with 'Generating' status
- setChatHistory((prev) => [...prev, {
- isUser: false,
- text: "",
- reasoning: "",
- status: "Generating", // Initially show 'Generating'
- provider: localActiveLLM,
- timestamp: new Date().toISOString()
- }]);
-
- await chatWithAI(sessionIdRef.current, text, localActiveLLM || "gemini", (event) => {
- setChatHistory((prev) => {
- const newHistory = [...prev];
- const lastMsg = { ...newHistory[newHistory.length - 1] };
-
- if (event.type === "reasoning") {
- if (!reasoningStartTime) reasoningStartTime = Date.now();
- lastMsg.reasoning += event.content;
- // Only update status to planning if we are in the brain's strategy phase.
- if (!lastMsg.status || (lastMsg.status === "Generating" || lastMsg.status === "Analyzing & Planning")) {
- lastMsg.status = "Analyzing & Planning";
- }
- } else if (event.type === "content") {
- if (reasoningStartTime && !lastMsg.thoughtDone) {
- reasoningDuration = Math.round((Date.now() - reasoningStartTime) / 1000);
- lastMsg.status = reasoningDuration > 0 ? `Thought for ${reasoningDuration}s` : null;
- lastMsg.thoughtDone = true;
- } else if (!reasoningStartTime && (lastMsg.status === "Generating" || lastMsg.status === "Analyzing & Planning")) {
- lastMsg.status = null;
- }
- lastMsg.text += event.content;
- } else if (event.type === "status") {
- lastMsg.status = event.content;
- } else if (event.type === "tool_start") {
- lastMsg.status = `Calling tool: ${event.name}`;
- } else if (event.type === "tool_result") {
- lastMsg.status = `Tool ${event.name} returned.`;
- }
-
- newHistory[newHistory.length - 1] = lastMsg;
- return newHistory;
- });
- });
-
- fetchTokenUsage();
- } catch (error) {
- setErrorMessage(error.message);
- setShowErrorModal(true);
- } finally {
- setIsProcessing(false);
- }
-
-
- }, [isConfigured, localActiveLLM, fetchTokenUsage]);
- const handleCancelChat = useCallback(async () => {
- if (!sessionIdRef.current) return;
- try {
- await cancelSession(sessionIdRef.current);
- // We don't set isProcessing false here immediately if we want to wait for the stream
- // to actually close, but the user wants immediate feedback.
- // RagPipeline already checks session.is_cancelled at the start of each turn.
- } catch (err) {
- console.warn("Failed to cancel session", err);
- }
- }, []);
-
- const handleSwitchSession = useCallback(async (targetSessionId) => {
- localStorage.setItem("sessionId_swarm_control", targetSessionId);
- setSessionId(targetSessionId);
- sessionIdRef.current = targetSessionId;
- setChatHistory([]);
-
- try {
- const sessionInfo = await getSession(targetSessionId);
- if (sessionInfo && sessionInfo.provider_name) {
- setLocalActiveLLM(sessionInfo.provider_name);
- }
- await fetchSessionHistory(targetSessionId);
- await fetchTokenUsage();
- } catch (error) {
- console.error("Failed to switch session:", error);
- }
- }, [fetchSessionHistory, fetchTokenUsage]);
-
- return {
- chatHistory,
- isProcessing,
- errorMessage,
- showErrorModal,
- tokenUsage,
- isConfigured,
- missingConfigs,
- handleSendChat,
- handleCancelChat,
- setShowErrorModal,
- handleSwitchSession,
- sessionId,
- userConfigData,
- localActiveLLM,
- setLocalActiveLLM
- };
-};
-
-export default useSwarmControl;
diff --git a/frontend/src/pages/HomePage.js b/frontend/src/pages/HomePage.js
deleted file mode 100644
index 81e7fed..0000000
--- a/frontend/src/pages/HomePage.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// HomePage.js
-import React from 'react';
-
-const HomePage = ({ onNavigate, isLoggedIn }) => {
- const buttonStyle = (enabled) =>
- enabled
- ? "w-full sm:w-auto bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
- : "w-full sm:w-auto bg-gray-400 text-gray-700 font-bold py-3 px-6 rounded-lg cursor-not-allowed opacity-50";
-
- const codeAssistantButtonStyle = (enabled) =>
- enabled
- ? "w-full sm:w-auto bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
- : "w-full sm:w-auto bg-gray-400 text-gray-700 font-bold py-3 px-6 rounded-lg cursor-not-allowed opacity-50";
-
- const handleAuthNavigate = (page) => {
- if (isLoggedIn) {
- onNavigate(page);
- }
- };
-
- return (
-
-
-
- Welcome to Cortex AI! ๐ง
-
-
- The on-premise AI platform for seamless, secure, and intelligent workflows.
- Leverage advanced RAG, VectorDB, and TTS/STT features in a single powerful hub.
-
-
- {/* New section for Swarm Control highlights */}
-
-
- Supercharge Your Workflows with Swarm Control ๐ค
-
-
- Our powerful orchestrator is enhanced with a library of Skills and System Prompts . Get instant, intelligent answers to your questions and leverage a distributed mesh to solve complex tasks.
-
-
-
-
- handleAuthNavigate("voice-chat")}
- className={buttonStyle(isLoggedIn)}
- disabled={!isLoggedIn}
- >
- Start Voice Chat ๐
-
- handleAuthNavigate("swarm-control")}
- className={codeAssistantButtonStyle(isLoggedIn)}
- disabled={!isLoggedIn}
- >
- Swarm Control ๐ป
-
- {!isLoggedIn && (
- onNavigate("login")}
- className="w-full sm:w-auto bg-gray-500 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50"
- >
- Log In ๐
-
- )}
-
-
-
- );
-};
-
-export default HomePage;
\ No newline at end of file
diff --git a/frontend/src/pages/LoginPage.js b/frontend/src/pages/LoginPage.js
deleted file mode 100644
index 318643e..0000000
--- a/frontend/src/pages/LoginPage.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { login, getUserStatus, logout } from '../services/apiService';
-
-const LoginPage = () => {
- const [user, setUser] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- // We now look for a 'user_id' in the URL, which is provided by the backend
- // after a successful OIDC login and callback.
- const params = new URLSearchParams(window.location.search);
- const userIdFromUrl = params.get('user_id');
-
- // First, check localStorage for a saved user ID for persistent login
- const storedUserId = localStorage.getItem('userId');
- const userId = userIdFromUrl || storedUserId;
-
- if (userId) {
- setIsLoading(true);
- // Fetch the full user details using the user ID from the URL.
- // This is a more secure and robust way to handle the final callback.
- const fetchUserDetails = async () => {
- try {
- const userStatus = await getUserStatus(userId);
- setUser(userStatus);
- // Store the user ID for future requests (e.g., in localStorage)
- localStorage.setItem('userId', userStatus.id);
- // Clean up the URL by removing the query parameter
- window.history.replaceState({}, document.title, window.location.pathname);
- } catch (err) {
- setError('Failed to get user status. Please try again.');
- console.error(err);
- } finally {
- setIsLoading(false);
- }
- };
- fetchUserDetails();
- }
- }, []);
-
- const handleLogin = () => {
- // Redirect to the backend's /users/login endpoint
- // The backend handles the OIDC redirect from there.
- login();
- };
-
- const handleLogout = async () => {
- setIsLoading(true);
- try {
- await logout();
- localStorage.removeItem('userId');
- setUser(null);
- setError(null);
- } catch (err) {
- setError('Failed to log out. Please try again.');
- console.error(err);
- } finally {
- setIsLoading(false);
- }
- };
-
- const renderContent = () => {
- if (isLoading) {
- return (
-
-
-
-
-
-
Processing login...
-
- );
- }
-
- if (error) {
- return (
-
- );
- }
-
- if (user) {
- return (
-
-
Login Successful!
-
Welcome, {user.email} .
-
User ID: {user.id}
-
- Log Out
-
-
- );
- }
-
- return (
- <>
-
Login
-
- Click the button below to log in using OpenID Connect (OIDC).
-
-
- Login with OIDC
-
- >
- );
- };
-
- return (
-
-
- {renderContent()}
-
-
- );
-};
-
-export default LoginPage;
diff --git a/frontend/src/pages/ProfilePage.js b/frontend/src/pages/ProfilePage.js
deleted file mode 100644
index dfc1f55..0000000
--- a/frontend/src/pages/ProfilePage.js
+++ /dev/null
@@ -1,382 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import {
- getUserProfile, updateUserProfile, getUserConfig, updateUserConfig,
- getUserAccessibleNodes, getUserNodePreferences, updateUserNodePreferences
-} from '../services/apiService';
-
-const ProfilePage = ({ onLogout }) => {
- const [profile, setProfile] = useState(null);
- const [config, setConfig] = useState(null);
- const [available, setAvailable] = useState({ llm: [], tts: [], stt: [] });
- const [accessibleNodes, setAccessibleNodes] = useState([]);
- const [nodePrefs, setNodePrefs] = useState({ default_node_ids: [], data_source: { source: 'empty', path: '' } });
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState({ type: '', text: '' });
- const [editData, setEditData] = useState({
- full_name: '',
- username: '',
- avatar_url: ''
- });
-
- useEffect(() => {
- loadData();
- }, []);
-
- const loadData = async () => {
- try {
- setLoading(true);
- const [prof, conf, nodes, nPrefs] = await Promise.all([
- getUserProfile(),
- getUserConfig(),
- getUserAccessibleNodes(),
- getUserNodePreferences()
- ]);
- setProfile(prof);
- setConfig(conf.preferences);
- setAccessibleNodes(nodes);
- setNodePrefs(nPrefs);
- setAvailable({
- llm: Object.entries(conf.effective?.llm?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null })),
- tts: Object.entries(conf.effective?.tts?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null, voice: p?.voice || null })),
- stt: Object.entries(conf.effective?.stt?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null }))
- });
- setEditData({
- full_name: prof.full_name || '',
- username: prof.username || '',
- avatar_url: prof.avatar_url || ''
- });
- } catch (err) {
- console.error("Failed to load profile data", err);
- setMessage({ type: 'error', text: 'Failed to load profile.' });
- } finally {
- setLoading(false);
- }
- };
-
- const handleProfileSubmit = async (e) => {
- e.preventDefault();
- try {
- setSaving(true);
- const updated = await updateUserProfile(editData);
- setProfile(updated);
- setMessage({ type: 'success', text: 'Profile updated successfully!' });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (err) {
- setMessage({ type: 'error', text: 'Failed to update profile.' });
- } finally {
- setSaving(false);
- }
- };
-
- const handlePreferenceChange = async (section, providerId) => {
- try {
- const newConfig = {
- ...config,
- [section]: { ...config[section], active_provider: providerId }
- };
- await updateUserConfig(newConfig);
- setConfig(newConfig);
- setMessage({ type: 'success', text: `Primary ${section.toUpperCase()} set to ${providerId}` });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (err) {
- setMessage({ type: 'error', text: 'Failed to update preferences.' });
- }
- };
-
- const handleNodePrefChange = async (updates) => {
- try {
- const newNodePrefs = { ...nodePrefs, ...updates };
- await updateUserNodePreferences(newNodePrefs);
- setNodePrefs(newNodePrefs);
- setMessage({ type: 'success', text: 'Node preferences updated.' });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (err) {
- setMessage({ type: 'error', text: 'Failed to update node preferences.' });
- }
- };
-
- const toggleDefaultNode = (nodeId) => {
- const current = nodePrefs.default_node_ids || [];
- const next = current.includes(nodeId)
- ? current.filter(id => id !== nodeId)
- : [...current, nodeId];
- handleNodePrefChange({ default_node_ids: next });
- };
-
- if (loading) {
- return (
-
- );
- }
-
- const inputClass = "w-full border border-gray-300 dark:border-gray-600 rounded-xl p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all";
- const labelClass = "block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 ml-1";
-
- return (
-
-
-
-
-
- {profile.avatar_url ?
: profile.email[0].toUpperCase()}
-
-
-
-
-
- {profile.full_name || profile.username || 'Citizen'}
-
-
{profile.email}
-
Member since {new Date(profile.created_at).toLocaleDateString()}
-
-
- {profile.role}
-
- {profile.group_name && (
-
- {profile.group_name} Group
-
- )}
-
-
- {onLogout && (
-
-
-
-
- Logout
-
- )}
-
-
-
- {message.text && (
-
- {message.text}
-
- )}
-
-
- {/* General Information */}
-
-
-
- General Information
-
-
-
-
- {/* Service Preferences */}
-
-
-
- Service Preferences
-
-
-
-
-
-
-
- {/* Node Defaults Section */}
- {accessibleNodes.length > 0 && (
-
-
-
- Default Node Attachment
-
-
-
Auto-attach these nodes to new sessions:
-
- {accessibleNodes.map(node => {
- const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id);
- return (
- toggleDefaultNode(node.node_id)}
- className={`px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border-2 transition-all ${isActive
- ? 'bg-emerald-600 border-emerald-600 text-white shadow-lg'
- : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 hover:border-emerald-300'
- }`}
- >
- {node.display_name}
-
- );
- })}
-
-
-
-
-
Default Sync Workspace Directory
-
- handleNodePrefChange({ data_source: { ...nodePrefs.data_source, source: e.target.value } })}
- className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-3 py-2 text-xs font-bold text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- >
- Empty Workspace
- Node Local Path
-
- {nodePrefs.data_source?.source === 'node_local' && (
- handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })}
- placeholder="/home/user/workspace"
- className="flex-1 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-3 py-2 text-xs font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- />
- )}
-
-
- Determines where the agent should look for files on the node when starting a chat.
-
-
-
- )}
-
-
-
-
- These selections determine which AI service and nodes are used by default when you interact with the hub. Individual session settings may override these.
-
-
-
-
-
- );
-};
-
-const ServiceSelect = ({ label, section, providers, active, statuses, onChange }) => {
- return (
-
-
{label}
-
- {providers.length === 0 ? (
-
No providers configured yet.
- ) : (
- providers.map(p => {
- const statusKey = `${section}_${p.id}`;
- const status = statuses?.[statusKey];
- const statusColor = status === 'success' ? 'bg-emerald-500' : status === 'error' ? 'bg-red-500' : 'bg-gray-300';
-
- const baseType = p.id.split('_')[0];
- const suffix = p.id.includes('_') ? p.id.split('_').slice(1).join('_') : '';
- const formattedLabel = baseType.charAt(0).toUpperCase() + baseType.slice(1) + (suffix ? ` (${suffix})` : '');
-
- const modelDisplay = p.model || null;
- const voiceDisplay = section === 'tts' ? (p.voice || null) : null;
- const isActive = active === p.id;
-
- return (
-
onChange(section, p.id)}
- title={[
- modelDisplay && `Model: ${modelDisplay}`,
- voiceDisplay && `Voice: ${voiceDisplay}`
- ].filter(Boolean).join(' ยท ') || undefined}
- className={`group flex flex-col items-start gap-0.5 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest border-2 transition-all ${isActive
- ? 'bg-indigo-600 border-indigo-600 text-white shadow-lg shadow-indigo-200 dark:shadow-none translate-y-[-2px]'
- : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-indigo-300 dark:hover:border-indigo-600'
- }`}
- >
-
- {modelDisplay && (
-
- {modelDisplay}
- {voiceDisplay && ยท {voiceDisplay} }
-
- )}
-
- );
- })
- )}
-
-
- );
-};
-
-export default ProfilePage;
diff --git a/frontend/src/pages/SettingsPage.js b/frontend/src/pages/SettingsPage.js
deleted file mode 100644
index dd4fc8e..0000000
--- a/frontend/src/pages/SettingsPage.js
+++ /dev/null
@@ -1,1500 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import {
- getUserConfig, updateUserConfig, exportUserConfig, importUserConfig,
- verifyProvider, getProviderModels, getAllProviders, getVoices,
- getAdminUsers, updateUserRole, getAdminGroups, createAdminGroup,
- updateAdminGroup, deleteAdminGroup, updateUserGroup, getAdminNodes,
- getSkills, getUserNodePreferences, updateUserNodePreferences,
- getUserAccessibleNodes
-} from '../services/apiService';
-
-const SettingsPage = () => {
- const [config, setConfig] = useState({ llm: {}, tts: {}, stt: {} });
- const [effective, setEffective] = useState({ llm: {}, tts: {}, stt: {} });
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState({ type: '', text: '' });
- const [activeConfigTab, setActiveConfigTab] = useState('llm');
- const [activeAdminTab, setActiveAdminTab] = useState('groups');
- const [userSearch, setUserSearch] = useState('');
- const [expandedProvider, setExpandedProvider] = useState(null);
- const [selectedNewProvider, setSelectedNewProvider] = useState('');
- const [verifying, setVerifying] = useState(null);
- const [fetchedModels, setFetchedModels] = useState({});
- const [providerLists, setProviderLists] = useState({ llm: [], tts: [], stt: [] });
- const [providerStatuses, setProviderStatuses] = useState({});
- const [voiceList, setVoiceList] = useState([]);
- const [showVoicesModal, setShowVoicesModal] = useState(false);
- const [voicesLoading, setVoicesLoading] = useState(false);
- const [allUsers, setAllUsers] = useState([]);
- const [usersLoading, setUsersLoading] = useState(false);
- const [allGroups, setAllGroups] = useState([]);
- const [groupsLoading, setGroupsLoading] = useState(false);
- const [editingGroup, setEditingGroup] = useState(null);
- const [addingSection, setAddingSection] = useState(null);
- const [addForm, setAddForm] = useState({ type: '', suffix: '', model: '', cloneFrom: '' });
- const [allNodes, setAllNodes] = useState([]);
- const [nodesLoading, setNodesLoading] = useState(false);
- const [allSkills, setAllSkills] = useState([]);
- const [skillsLoading, setSkillsLoading] = useState(false);
- const [accessibleNodes, setAccessibleNodes] = useState([]);
- const [nodePrefs, setNodePrefs] = useState({ default_node_ids: [], data_source: { source: 'empty', path: '' } });
- const fileInputRef = useRef(null);
-
- const handleViewVoices = async (providerId, apiKey = null) => {
- setShowVoicesModal(true);
- setVoicesLoading(true);
- setVoiceList([]); // Clear previous list while loading
- try {
- const voices = await getVoices(providerId, apiKey);
- setVoiceList(voices);
- } catch (e) {
- console.error(e);
- } finally {
- setVoicesLoading(false);
- }
- };
-
- useEffect(() => {
- const fetchProviders = async () => {
- try {
- const [llm, tts, stt] = await Promise.all([
- getAllProviders('llm'),
- getAllProviders('tts'),
- getAllProviders('stt')
- ]);
- setProviderLists({
- llm: llm.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : id.charAt(0).toUpperCase() + id.slice(1) })).sort((a, b) => a.label.localeCompare(b.label)),
- tts: tts.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : id.charAt(0).toUpperCase() + id.slice(1) })).sort((a, b) => a.label.localeCompare(b.label)),
- stt: stt.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : (id === 'google_gemini' ? 'Google Gemini' : id.charAt(0).toUpperCase() + id.slice(1)) })).sort((a, b) => a.label.localeCompare(b.label))
- });
- } catch (e) {
- console.error("Failed to load provider lists", e);
- }
- };
- fetchProviders();
- }, []);
-
- useEffect(() => {
- loadConfig();
- loadUsers();
- loadGroups();
- loadNodes();
- loadSkills();
- loadPersonalNodePrefs();
- }, []);
-
- const loadPersonalNodePrefs = async () => {
- try {
- const [nodes, prefs] = await Promise.all([
- getUserAccessibleNodes(),
- getUserNodePreferences()
- ]);
- setAccessibleNodes(nodes);
- setNodePrefs(prefs);
- } catch (e) {
- console.error("Failed to load personal node prefs", e);
- }
- };
-
- const loadSkills = async () => {
- try {
- setSkillsLoading(true);
- const skills = await getSkills();
- setAllSkills(skills);
- } catch (e) {
- console.error("Failed to load skills", e);
- } finally {
- setSkillsLoading(false);
- }
- };
-
- const loadNodes = async () => {
- try {
- setNodesLoading(true);
- const nodes = await getAdminNodes();
- setAllNodes(nodes);
- } catch (e) {
- console.error("Failed to load nodes", e);
- } finally {
- setNodesLoading(false);
- }
- };
-
- const loadGroups = async () => {
- try {
- setGroupsLoading(true);
- const groups = await getAdminGroups();
- setAllGroups(groups);
- } catch (e) {
- console.error("Failed to load groups", e);
- } finally {
- setGroupsLoading(false);
- }
- };
-
- const loadUsers = async () => {
- try {
- setUsersLoading(true);
- const users = await getAdminUsers();
- setAllUsers(users);
- } catch (e) {
- console.error("Failed to load users", e);
- } finally {
- setUsersLoading(false);
- }
- };
-
- const handleRoleToggle = async (user) => {
- const newRole = user.role === 'admin' ? 'user' : 'admin';
- try {
- await updateUserRole(user.id, newRole);
- setMessage({ type: 'success', text: `Role for ${user.username || user.email} updated to ${newRole}` });
- loadUsers(); // refresh list
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (e) {
- setMessage({ type: 'error', text: e.message || 'Failed to update role' });
- }
- };
-
- const handleGroupChange = async (targetUserId, groupId) => {
- try {
- await updateUserGroup(targetUserId, groupId);
- setMessage({ type: 'success', text: `User group updated successfully` });
- loadUsers();
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (e) {
- setMessage({ type: 'error', text: e.message || 'Failed to update group' });
- }
- };
-
- const handleNodePrefChange = async (updates) => {
- try {
- const newNodePrefs = { ...nodePrefs, ...updates };
- await updateUserNodePreferences(newNodePrefs);
- setNodePrefs(newNodePrefs);
- setMessage({ type: 'success', text: 'Personal node preferences updated.' });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (err) {
- setMessage({ type: 'error', text: 'Failed to update node preferences.' });
- }
- };
-
- const toggleDefaultNode = (nodeId) => {
- const current = nodePrefs.default_node_ids || [];
- const next = current.includes(nodeId)
- ? current.filter(id => id !== nodeId)
- : [...current, nodeId];
- handleNodePrefChange({ default_node_ids: next });
- };
-
- const handleSaveGroup = async (e) => {
- e.preventDefault();
- try {
- setSaving(true);
- if (editingGroup.id === 'new') {
- const { id, ...data } = editingGroup;
- await createAdminGroup(data);
- } else {
- await updateAdminGroup(editingGroup.id, editingGroup);
- }
- setMessage({ type: 'success', text: 'Group saved successfully!' });
- setEditingGroup(null);
- loadGroups();
- loadUsers();
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (e) {
- setMessage({ type: 'error', text: e.message || 'Failed to save group' });
- } finally {
- setSaving(false);
- }
- };
-
- const handleDeleteGroup = async (groupId) => {
- if (!window.confirm("Are you sure? Users in this group will be moved to 'Ungrouped'.")) return;
- try {
- await deleteAdminGroup(groupId);
- setMessage({ type: 'success', text: 'Group deleted' });
- loadGroups();
- loadUsers();
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (e) {
- setMessage({ type: 'error', text: e.message || 'Failed to delete group' });
- }
- };
-
- const loadConfig = async () => {
- try {
- setLoading(true);
- const data = await getUserConfig();
-
- // Pre-seed config with effective providers if the user's config is empty
- const seedEffective = (prefSec, effSec) => {
- if (prefSec && prefSec.providers && Object.keys(prefSec.providers).length > 0) return prefSec;
- return {
- ...prefSec,
- providers: { ...(effSec?.providers || {}) },
- active_provider: prefSec?.active_provider || effSec?.active_provider
- };
- };
-
- setConfig({
- llm: seedEffective(data.preferences?.llm, data.effective?.llm),
- tts: seedEffective(data.preferences?.tts, data.effective?.tts),
- stt: seedEffective(data.preferences?.stt, data.effective?.stt)
- });
- setProviderStatuses(data.preferences?.statuses || {});
- setEffective(data.effective || { llm: {}, tts: {}, stt: {} });
-
- setMessage({ type: '', text: '' });
- } catch (err) {
- console.error("Error loading config:", err);
- setMessage({ type: 'error', text: 'Failed to load configuration.' });
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- if (expandedProvider) {
- const parts = expandedProvider.split('_');
- const sectionKey = parts[0];
- const providerId = parts.slice(1).join('_');
-
- const fetchKey = `${sectionKey}_${providerId}`;
- if (!fetchedModels[fetchKey]) {
- getProviderModels(providerId, sectionKey).then(models => {
- setFetchedModels(prev => ({ ...prev, [fetchKey]: models }));
- }).catch(e => console.warn("Failed fetching models for", providerId, "section", sectionKey));
- }
- }
- }, [expandedProvider, fetchedModels]);
-
- // Pre-fetch model list for the selected type in the add-new-instance form
- useEffect(() => {
- if (addingSection && addForm.type) {
- const fetchKey = `${addingSection}_${addForm.type}`;
- if (!fetchedModels[fetchKey]) {
- getProviderModels(addForm.type, addingSection).then(models => {
- setFetchedModels(prev => ({ ...prev, [fetchKey]: models }));
- }).catch(() => { });
- }
- }
- }, [addingSection, addForm.type, fetchedModels]);
-
- const handleSave = async (e) => {
- e.preventDefault();
- try {
- setSaving(true);
- setMessage({ type: '', text: 'Saving and verifying configuration...' });
-
- // Before saving, let's identify any "active" providers that have been modified
- // (i.e. they are grey/have no status) and run a quick verification for them.
- const updatedStatuses = { ...providerStatuses };
- const sections = ['llm', 'tts', 'stt'];
-
- for (const section of sections) {
- const activeId = config[section]?.active_provider;
- if (activeId && !updatedStatuses[`${section}_${activeId}`]) {
- const providerPrefs = config[section]?.providers?.[activeId];
- if (providerPrefs && providerPrefs.api_key) {
- try {
- const res = await verifyProvider(section, {
- provider_name: activeId,
- provider_type: providerPrefs.provider_type || activeId.split('_')[0],
- api_key: providerPrefs.api_key,
- model: providerPrefs.model,
- voice: providerPrefs.voice
- });
- updatedStatuses[`${section}_${activeId}`] = res.success ? 'success' : 'error';
- } catch (err) {
- updatedStatuses[`${section}_${activeId}`] = 'error';
- }
- }
- }
- }
-
- setProviderStatuses(updatedStatuses);
- const payload = { ...config, statuses: updatedStatuses };
- await updateUserConfig(payload);
-
- // reload after save to get latest effective config
- await loadConfig();
- setMessage({ type: 'success', text: 'Settings saved and verified successfully!' });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (err) {
- console.error("Error saving config:", err);
- setMessage({ type: 'error', text: 'Failed to save configuration: ' + (err.message || "Unknown error") });
- } finally {
- setSaving(false);
- }
- };
-
- const handleExport = async () => {
- try {
- const response = await exportUserConfig();
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = "config.yaml";
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- } catch (error) {
- console.error("Export Error: ", error);
- setMessage({ type: 'error', text: 'Failed to export YAML.' });
- }
- };
-
- const handleGrantToAll = async (section, providerId) => {
- if (!window.confirm(`Are you sure? This will whitelist ${providerId} for ALL existing groups.`)) return;
- try {
- setSaving(true);
- setMessage({ type: '', text: `Syncing group policies for ${providerId}...` });
- for (const group of allGroups) {
- const currentPolicy = group.policy || { llm: [], tts: [], stt: [] };
- const sectionList = currentPolicy[section] || [];
- if (!sectionList.includes(providerId)) {
- const newPolicy = { ...currentPolicy, [section]: [...sectionList, providerId] };
- await updateAdminGroup(group.id, { ...group, policy: newPolicy });
- }
- }
- await loadGroups();
- setMessage({ type: 'success', text: `Global access granted for ${providerId}!` });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (e) {
- console.error(e);
- setMessage({ type: 'error', text: 'Failed to sync group access.' });
- } finally {
- setSaving(false);
- }
- };
-
- const handleImport = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- try {
- setLoading(true);
- const formData = new FormData();
- formData.append('file', file);
- await importUserConfig(formData);
- await loadConfig();
- setMessage({ type: 'success', text: 'Configuration imported successfully!' });
- setTimeout(() => setMessage({ type: '', text: '' }), 3000);
- } catch (error) {
- console.error("Import Error: ", error);
- setMessage({ type: 'error', text: 'Failed to import YAML: ' + error.message });
- } finally {
- setLoading(false);
- if (fileInputRef.current) fileInputRef.current.value = '';
- }
- };
-
- const handleChange = (section, field, value, providerId = null) => {
- if (field === 'providers' && providerId) {
- setProviderStatuses(prev => {
- const updated = { ...prev };
- delete updated[`${section}_${providerId}`];
- return updated;
- });
- }
- setConfig((prev) => ({
- ...prev,
- [section]: {
- ...prev[section],
- [field]: value
- }
- }));
- };
-
- if (loading) {
- return (
-
- );
- }
-
- const inputClass = "w-full border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors duration-200 shadow-sm";
- const labelClass = "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2";
- const sectionClass = "animate-fade-in";
-
- const renderProviderSection = (sectionKey, providerDefs, allowVoice = false) => {
- const activeProviderIds = new Set([
- ...Object.keys(config[sectionKey]?.providers || {})
- ]);
- const activeProviders = Array.from(activeProviderIds).map(id => {
- const baseP = providerDefs.find(p => p.id === id);
- if (baseP) return baseP;
- // Handle suffixed IDs (e.g. gemini_2)
- const parts = id.split('_');
- let baseId = parts[0];
- // Special case for google_gemini
- if (id.startsWith('google_gemini_')) baseId = 'google_gemini';
-
- const baseDef = providerDefs.find(p => p.id === baseId);
- const suffix = id.replace(baseId + '_', '');
- return {
- id: id,
- label: baseDef ? `${baseDef.label} (${suffix})` : id
- };
- }).sort((a, b) => a.label.localeCompare(b.label));
-
-
- const handleAddInstance = () => {
- if (!addForm.type) return;
- const newId = addForm.suffix ? `${addForm.type}_${addForm.suffix.toLowerCase().replace(/\s+/g, '_')}` : addForm.type;
-
- if (activeProviderIds.has(newId)) {
- setMessage({ type: 'error', text: `Instance "${newId}" already exists.` });
- return;
- }
-
- // Build initial provider data
- const initData = { provider_type: addForm.type };
-
- // Store a _clone_from marker โ the backend will resolve the real API key
- // from the source provider. We never have the plaintext key on the frontend.
- if (addForm.cloneFrom) {
- initData._clone_from = addForm.cloneFrom;
- }
-
- // Pre-set model (or voice for Google Cloud TTS) if specified
- if (addForm.model.trim()) {
- if (sectionKey === 'tts' && addForm.type === 'gcloud_tts') {
- initData.voice = addForm.model.trim();
- } else {
- initData.model = addForm.model.trim();
- }
- }
-
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[newId] = initData;
- handleChange(sectionKey, 'providers', newProviders, newId);
- setAddingSection(null);
- setAddForm({ type: '', suffix: '', model: '', cloneFrom: '' });
- setExpandedProvider(`${sectionKey}_${newId}`);
- };
-
- // Existing instances of the same type that have an API key โ for cloning
- const cloneableSources = Array.from(activeProviderIds).filter(id => {
- const baseType = id.startsWith('google_gemini') ? 'google_gemini' : id.split('_')[0];
- return baseType === addForm.type && id !== addForm.type + (addForm.suffix ? '_' + addForm.suffix.toLowerCase().replace(/\s+/g, '_') : '');
- });
-
- const handleDeleteProvider = (providerId) => {
- const newProviders = { ...((config[sectionKey] && config[sectionKey].providers) || {}) };
- delete newProviders[providerId];
- handleChange(sectionKey, 'providers', newProviders, providerId);
- if (expandedProvider === `${sectionKey}_${providerId}`) setExpandedProvider(null);
- };
-
-
-
- const handleVerifyProvider = async (providerId, providerPrefs) => {
- try {
- setVerifying(`${sectionKey}_${providerId}`);
- setMessage({ type: '', text: '' });
- const payload = {
- provider_name: providerId,
- provider_type: providerPrefs.provider_type,
- api_key: providerPrefs.api_key,
- model: providerPrefs.model,
- voice: providerPrefs.voice
- };
- const res = await verifyProvider(sectionKey, payload);
- if (res.success) {
- const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'success' };
- setProviderStatuses(newStatuses);
- await updateUserConfig({ ...config, statuses: newStatuses });
- setMessage({ type: 'success', text: `Verified ${providerId} successfully!` });
- } else {
- const newStatuses = { ...providerStatuses, [`${sectionKey}_${providerId}`]: 'error' };
- setProviderStatuses(newStatuses);
- await updateUserConfig({ ...config, statuses: newStatuses });
- setMessage({ type: 'error', text: `Verification failed for ${providerId}: ${res.message}` });
- }
- } catch (err) {
- setMessage({ type: 'error', text: `Error verifying ${providerId}.` });
- } finally {
- setVerifying(null);
- setTimeout(() => setMessage({ type: '', text: '' }), 5000);
- }
- };
-
- return (
-
- {/* Header & Add Form */}
-
-
-
-
-
Resource Instances
-
Configure specific account credentials
-
-
-
- {addingSection !== sectionKey ? (
-
{ setAddingSection(sectionKey); setAddForm({ type: '', suffix: '', model: '', cloneFrom: '' }); }}
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] font-black uppercase tracking-widest rounded-lg shadow-md transition-all flex items-center gap-2"
- >
-
- Register New Instance
-
- ) : (
-
-
New Provider Instance
-
- {/* Row 1: Type + Label suffix */}
-
-
- Provider Type
- setAddForm({ type: e.target.value, suffix: '', model: '', cloneFrom: '' })}
- className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm font-bold text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
- >
- Select typeโฆ
- {providerDefs.map(p => (
- {p.label}
- ))}
-
-
-
-
- Label Suffix (e.g. flash25, 3pro)
-
-
setAddForm({ ...addForm, suffix: e.target.value })}
- className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
- />
- {addForm.type && addForm.suffix && (
-
- ID: {addForm.type}_{addForm.suffix.toLowerCase().replace(/\s+/g, '_')}
-
- )}
-
-
-
- {/* Row 2: Model + Clone-from */}
-
-
-
- {sectionKey === 'tts' && addForm.type === 'gcloud_tts' ? 'Pre-set Voice' : 'Pre-set Model'} (users will see this)
-
- {addForm.type && fetchedModels[`${sectionKey}_${addForm.type}`]?.length > 0 ? (
- setAddForm({ ...addForm, model: e.target.value })}
- className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
- >
- -- Choose {sectionKey === 'tts' && addForm.type === 'gcloud_tts' ? 'voice' : 'model'} --
- {fetchedModels[`${sectionKey}_${addForm.type}`].map(m => (
-
- {m.model_name}{m.max_input_tokens ? ` (${Math.round(m.max_input_tokens / 1000)}k ctx)` : ''}
-
- ))}
-
- ) : (
- setAddForm({ ...addForm, model: e.target.value })}
- className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
- />
- )}
-
- {cloneableSources.length > 0 && (
-
-
- Inherit API Key From (reuse existing key)
-
-
setAddForm({ ...addForm, cloneFrom: e.target.value })}
- className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
- >
- -- Enter key manually --
- {cloneableSources.map(id => (
- {id}
- ))}
-
- {addForm.cloneFrom && (
-
โ API key will be copied from "{addForm.cloneFrom}" on save
- )}
-
- )}
-
-
- {/* Action buttons */}
-
-
{ setAddingSection(null); setAddForm({ type: '', suffix: '', model: '', cloneFrom: '' }); }}
- className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg text-sm font-semibold hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
-
-
-
- Add Instance
-
-
-
- )}
-
-
-
-
-
- {activeProviders.length === 0 && (
-
No providers enabled. Add one above.
- )}
- {activeProviders.map((provider) => {
- const isExpanded = expandedProvider === `${sectionKey}_${provider.id}`;
- const providerPrefs = config[sectionKey]?.providers?.[provider.id] || {};
- const providerEff = effective[sectionKey]?.providers?.[provider.id] || {};
-
- let displayMeta = providerPrefs.model || providerEff.model;
- if (sectionKey === 'tts' && provider.id.startsWith('gcloud_tts')) {
- displayMeta = providerPrefs.voice || providerEff.voice;
- }
-
- return (
-
-
setExpandedProvider(isExpanded ? null : `${sectionKey}_${provider.id}`)}
- className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
- >
-
-
- {provider.label}
-
-
-
- {displayMeta ? (
-
- {displayMeta}
-
- ) : null}
-
-
-
{
- e.stopPropagation();
- handleDeleteProvider(provider.id);
- }}
- className="text-[10px] font-black uppercase tracking-widest text-red-600 bg-red-50 hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50 dark:text-red-400 px-2 py-1.5 rounded transition-all border border-red-100 dark:border-red-800/50"
- >
- Remove
-
-
{
- e.stopPropagation();
- handleVerifyProvider(provider.id, providerPrefs);
- }}
- className="text-xs text-emerald-700 bg-emerald-50 hover:bg-emerald-100 dark:bg-emerald-900/40 dark:hover:bg-emerald-900/60 dark:text-emerald-300 font-semibold px-2 py-1 rounded transition-colors"
- >
- {verifying === `${sectionKey}_${provider.id}` ? 'Testing...' : 'Test'}
-
-
-
-
-
-
- {isExpanded && (
-
-
-
-
- API Key
-
- <>
-
{
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- const p = { ...providerPrefs, api_key: e.target.value };
- delete p._clone_from;
- newProviders[provider.id] = p;
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }}
- onFocus={(e) => {
- if (e.target.value.includes('***')) {
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- const p = { ...providerPrefs, api_key: '' };
- delete p._clone_from;
- newProviders[provider.id] = p;
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }
- }}
- placeholder="sk-..."
- className={inputClass}
- />
-
Specify your API key for {provider.label}.
- >
-
-
- {!(sectionKey === 'tts' && provider.id === 'gcloud_tts') && (
-
-
Model Selection
- {fetchedModels[`${sectionKey}_${provider.id}`] && fetchedModels[`${sectionKey}_${provider.id}`].length > 0 ? (
-
{
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[provider.id] = { ...providerPrefs, model: e.target.value };
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }}
- className={inputClass}
- >
- -- Let Backend Decide Default --
- {fetchedModels[`${sectionKey}_${provider.id}`].map(m => (
-
- {m.model_name} {m.max_input_tokens ? `(Context: ${Math.round(m.max_input_tokens / 1000)}k)` : ''}
-
- ))}
-
- ) : (
-
{
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[provider.id] = { ...providerPrefs, model: e.target.value };
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }}
- placeholder={provider.id === 'general' ? "E.g. vertex_ai/gemini-1.5-flash" : (sectionKey === 'llm' ? "E.g. gpt-4, claude-3-opus" : "E.g. whisper-1, gemini-1.5-flash")}
- className={inputClass}
- />
- )}
-
- Specify exactly which model to pass to the provider API. Active default: {providerEff.model || 'None'}
-
-
- )}
-
- {allowVoice && (
-
-
- Voice Name
- handleViewVoices(provider.id, providerPrefs.api_key)} className="flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-900/60 text-blue-600 dark:text-blue-400 text-xs font-bold hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors shadow-sm" title="View available voices">?
-
-
{
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[provider.id] = { ...providerPrefs, voice: e.target.value };
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }}
- placeholder="E.g., Kore, en-US-Journey-F"
- className={inputClass}
- />
-
- Active default: {providerEff.voice || 'None'}
-
-
- )}
-
- {/* Custom Parameters Section */}
-
-
- Custom Parameters
- (e.g., vertex_project, vertex_location)
-
-
-
- {Object.entries(providerPrefs).map(([key, value]) => {
- // Filter out standard fields to only show "custom" ones here
- if (['api_key', 'model', 'voice'].includes(key)) return null;
- return (
-
-
- {key}:
- {value}
-
-
{
- const { [key]: deleted, ...rest } = providerPrefs;
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[provider.id] = rest;
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- }}
- className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
- >
-
-
-
- );
- })}
-
-
-
-
-
-
-
-
-
-
{
- const k = document.getElementById(`new-key-${sectionKey}-${provider.id}`).value.trim();
- const v = document.getElementById(`new-val-${sectionKey}-${provider.id}`).value.trim();
- if (k && v) {
- const newProviders = { ...(config[sectionKey]?.providers || {}) };
- newProviders[provider.id] = { ...providerPrefs, [k]: v };
- handleChange(sectionKey, 'providers', newProviders, provider.id);
- document.getElementById(`new-key-${sectionKey}-${provider.id}`).value = '';
- document.getElementById(`new-val-${sectionKey}-${provider.id}`).value = '';
- }
- }}
- className="px-6 py-3 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-xl text-sm font-bold hover:bg-indigo-100 dark:hover:bg-indigo-900/60 transition-all shadow-sm border border-indigo-200 dark:border-indigo-800/50 flex-shrink-0"
- >
- Add Parameter
-
-
-
-
-
-
-
Access Control
-
Manage which groups can use this provider.
-
-
{
- e.stopPropagation();
- handleGrantToAll(sectionKey, provider.id);
- }}
- className="px-4 py-2 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-lg text-sm font-bold hover:bg-indigo-100 dark:hover:bg-indigo-900/60 transition-all shadow-sm border border-indigo-200 dark:border-indigo-800/50"
- title="Add this provider to ALL group whitelists"
- >
- Grant All Groups
-
-
-
- )}
-
- );
- })}
-
-
- );
- };
-
- const filteredUsers = allUsers.filter(u =>
- (u.username || '').toLowerCase().includes(userSearch.toLowerCase()) ||
- (u.email || '').toLowerCase().includes(userSearch.toLowerCase()) ||
- (u.full_name || '').toLowerCase().includes(userSearch.toLowerCase())
- );
-
- const sortedGroups = [...allGroups].sort((a, b) => {
- if (a.id === 'ungrouped') return -1;
- if (b.id === 'ungrouped') return 1;
- return a.name.localeCompare(b.name);
- });
-
- return (
-
-
-
-
Configuration
-
-
-
fileInputRef.current?.click()}
- className="text-sm font-semibold px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 transition-colors shadow-sm flex items-center gap-2"
- >
-
- Import
-
-
-
- Export
-
-
-
-
- Customize your AI models, backend API tokens, and providers. These settings override system defaults.
-
-
- {message.text && (
-
- {message.text}
-
- )}
-
-
- {/* Card 1: AI Provider Configuration */}
-
-
-
-
- AI Resource Configuration
-
-
Manage your providers, models, and API keys
-
-
- {/* Config Tabs */}
-
- {['llm', 'tts', 'stt'].map((tab) => (
- setActiveConfigTab(tab)}
- className={`flex-1 min-w-[100px] py-4 text-[10px] font-black uppercase tracking-widest transition-all duration-200 focus:outline-none ${activeConfigTab === tab
- ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800'
- : 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-700/30'
- }`}
- >
- {tab === 'llm' ? 'Models' : tab === 'tts' ? 'TTS' : 'STT'}
-
- ))}
-
-
-
-
-
- {/* Card 2: Team & Access Management */}
-
-
-
-
- Identity & Access Governance
-
-
Define groups, policies, and manage members
-
-
- {/* Admin Tabs */}
-
- {['groups', 'users', 'personal'].map((tab) => (
- setActiveAdminTab(tab)}
- className={`flex-1 min-w-[100px] py-4 text-[10px] font-black uppercase tracking-widest transition-all duration-200 focus:outline-none ${activeAdminTab === tab
- ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 dark:border-indigo-400 bg-white dark:bg-gray-800'
- : 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-700/30'
- }`}
- >
- {tab === 'groups' ? 'Groups' : tab === 'users' ? 'Users' : 'My Profile'}
-
- ))}
-
-
-
- {/* Groups Management */}
- {activeAdminTab === 'groups' && (
-
- {!editingGroup ? (
-
-
-
- Registered Groups
-
-
setEditingGroup({ id: 'new', name: '', description: '', policy: { llm: [], tts: [], stt: [], nodes: [], skills: [] } })}
- className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] font-black uppercase tracking-widest rounded-lg shadow-md transition-all flex items-center gap-2"
- >
-
- Add Group
-
-
-
-
- {sortedGroups.map((g) => (
-
-
-
- {g.id === 'ungrouped' ? 'Standard / Guest Policy' : g.name}
- {g.id === 'ungrouped' && Global Fallback }
-
-
- {g.id === 'ungrouped' ? 'Baseline access for all unassigned members.' : (g.description || 'No description')}
-
-
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => (
-
-
{section}
-
- {g.policy?.[section]?.length > 0 ? (
- g.policy?.[section].slice(0, 3).map(p => (
-
- {p[0].toUpperCase()}
-
- ))
- ) : (
-
None
- )}
- {g.policy?.[section]?.length > 3 && (
-
- +{g.policy?.[section].length - 3}
-
- )}
-
-
- ))}
-
-
-
-
setEditingGroup(g)}
- className="p-2 text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-colors"
- >
-
-
- {g.id !== 'ungrouped' && (
-
handleDeleteGroup(g.id)}
- className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
- >
-
-
- )}
-
-
- ))}
-
-
- ) : (
-
- {/* (Group editing form - unchanged logic, just cleaner container) */}
-
-
setEditingGroup(null)} className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
-
-
-
- {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`}
-
- {editingGroup.id === 'ungrouped' && (
-
-
- System Group
-
- )}
-
-
-
-
-
-
Group Name
-
editingGroup.id !== 'ungrouped' && setEditingGroup({ ...editingGroup, name: e.target.value })}
- readOnly={editingGroup.id === 'ungrouped'}
- placeholder="Engineering, Designers, etc."
- className={`${inputClass} ${editingGroup.id === 'ungrouped'
- ? 'opacity-60 cursor-not-allowed bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
- : (editingGroup.name.trim() &&
- allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase())
- ? '!border-red-400 dark:!border-red-600 !ring-red-300'
- : '')
- }`}
- />
- {editingGroup.id === 'ungrouped' ? (
-
-
- System group name is locked. Only the access policy can be changed.
-
- ) : editingGroup.name.trim() &&
- allGroups.some(g => g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase()) && (
-
-
- A group with this name already exists
-
- )}
-
-
- Description
- setEditingGroup({ ...editingGroup, description: e.target.value })}
- placeholder="Short description of this group..."
- className={inputClass}
- />
-
-
-
-
-
Provider Access Policy (Whitelists)
-
-
- {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => (
-
-
-
{section === 'nodes' ? 'Accessible Nodes' : `${section} Access`}
-
- {
- let availableIds = [];
- if (section === 'nodes') {
- availableIds = allNodes.map(n => n.node_id);
- } else if (section === 'skills') {
- availableIds = allSkills.map(s => s.name);
- } else {
- availableIds = effective[section]?.providers ? Object.keys(effective[section].providers) : [];
- }
- setEditingGroup({ ...editingGroup, policy: { ...editingGroup.policy, [section]: availableIds } })
- }} className="text-[10px] font-bold text-indigo-600 dark:text-indigo-400 hover:underline">Select All
- setEditingGroup({ ...editingGroup, policy: { ...editingGroup.policy, [section]: [] } })} className="text-[10px] font-bold text-red-600 dark:text-red-400 hover:underline">Clear
-
-
-
- {(section === 'nodes' ? allNodes.map(n => ({ id: n.node_id, label: n.display_name })) :
- (section === 'skills' ? allSkills.filter(s => !s.is_system).map(s => ({ id: s.name, label: s.name })) :
- (effective[section]?.providers ? Object.keys(effective[section].providers) : []).map(pId => {
- const baseType = pId.split('_')[0];
- const baseDef = providerLists[section].find(ld => ld.id === baseType || ld.id === pId);
- return { id: pId, label: baseDef ? (pId.includes('_') ? `${baseDef.label} (${pId.split('_').slice(1).join('_')})` : baseDef.label) : pId };
- }))).map(item => {
- const isChecked = (editingGroup.policy?.[section] || []).includes(item.id);
- return (
-
- {
- const current = editingGroup.policy?.[section] || [];
- const next = e.target.checked ? [...current, item.id] : current.filter(x => x !== item.id);
- setEditingGroup({ ...editingGroup, policy: { ...editingGroup.policy, [section]: next } });
- }}
- className="rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500"
- />
- {item.label}
-
- );
- })}
-
- {section === 'nodes' && allNodes.length === 0 && (
-
No agent nodes registered yet.
- )}
-
- ))}
-
-
-
-
- setEditingGroup(null)} className="px-6 py-2 rounded-xl text-sm font-bold text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">Cancel
- g.id !== editingGroup.id && g.name.toLowerCase() === editingGroup.name.trim().toLowerCase())} className="px-8 py-2 bg-indigo-600 text-white rounded-xl text-sm font-black uppercase tracking-widest shadow-lg shadow-indigo-200 dark:shadow-indigo-900/50 hover:bg-indigo-700 disabled:opacity-50 transition-all">
- {saving ? 'Saving...' : 'Save Group'}
-
-
-
-
- )}
-
- )}
-
- {/* Users Management */}
- {activeAdminTab === 'users' && (
-
-
-
-
- Active Roster
- {filteredUsers.length}
-
-
-
-
setUserSearch(e.target.value)}
- placeholder="Search by name, email..."
- className="w-full text-xs p-2.5 pl-9 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
- />
-
-
-
-
-
-
-
-
-
-
-
- Member
- Policy Group
- Activity Auditing
- Actions
-
-
-
- {filteredUsers.map((u) => (
-
-
-
-
- {(u.username || u.email || '?')[0].toUpperCase()}
-
-
-
{u.username || u.email}
-
{u.role}
-
-
-
-
- handleGroupChange(u.id, e.target.value)}
- className="text-xs p-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg outline-none focus:ring-1 focus:ring-indigo-500 transition-all font-bold text-gray-600 dark:text-gray-300 min-w-[150px]"
- >
- {sortedGroups.map(g => (
-
- {g.id === 'ungrouped' ? 'Standard / Guest' : g.name}
-
- ))}
-
-
-
-
-
- Join:
- {new Date(u.created_at).toLocaleDateString()}
-
-
- Last:
-
- {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}
-
-
-
-
-
- { e.preventDefault(); handleRoleToggle(u); }}
- className={`text-[9px] font-black uppercase tracking-widest transition-all ${u.role === 'admin'
- ? 'text-red-600 hover:text-red-700'
- : 'text-indigo-600 hover:text-indigo-700'
- }`}
- >
- {u.role === 'admin' ? 'Demote' : 'Promote'}
-
-
-
- ))}
-
-
- {allUsers.length === 0 && !usersLoading && (
-
No other users found.
- )}
-
-
-
- )}
-
- {/* Personal Settings */}
- {activeAdminTab === 'personal' && (
-
-
-
-
-
-
My Preferences
-
Customize your individual experience
-
-
-
-
- {accessibleNodes.length > 0 ? (
-
-
Default Node Attachment
-
Auto-attach these nodes to new sessions:
-
- {accessibleNodes.map(node => {
- const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id);
- return (
- toggleDefaultNode(node.node_id)}
- className={`px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-widest border-2 transition-all ${isActive
- ? 'bg-emerald-600 border-emerald-600 text-white shadow-lg shadow-emerald-200 dark:shadow-none translate-y-[-1px]'
- : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 hover:border-emerald-300'
- }`}
- >
- {node.display_name}
-
- );
- })}
-
-
- ) : (
-
-
No agent nodes are currently assigned to your group.
-
- )}
-
-
-
Default Sync Workspace Directory
-
- handleNodePrefChange({ data_source: { ...nodePrefs.data_source, source: e.target.value } })}
- className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 text-xs font-bold text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm"
- >
- Empty Workspace
- Node Local Path
-
- {nodePrefs.data_source?.source === 'node_local' && (
- handleNodePrefChange({ data_source: { ...nodePrefs.data_source, path: e.target.value } })}
- placeholder="/home/user/workspace"
- className="flex-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 text-xs font-mono text-indigo-600 dark:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm"
- />
- )}
-
-
- Determines where the agent should look for files on the node when starting a chat.
-
-
-
-
-
- )}
-
-
-
-
- {showVoicesModal && (
-
setShowVoicesModal(false)}>
-
e.stopPropagation()}>
-
-
-
Available Cloud Voices
-
Found {voiceList.length} voices to choose from.
-
Highlighted voices (Chirp, Journey, Studio) use advanced AI for highest quality.
-
-
setShowVoicesModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-3xl font-bold focus:outline-none">×
-
-
- {voicesLoading ? (
-
- ) : voiceList.length > 0 ? (
-
- {voiceList.map((v, i) => {
- let highlight = v.toLowerCase().includes('chirp') || v.toLowerCase().includes('journey') || v.toLowerCase().includes('studio');
- return (
-
- {v}
-
- )
- })}
-
- ) : (
-
No voices found. Make sure your API key is configured and valid.
- )}
-
-
- Double-click a name to select it, then paste it into the field.
- setShowVoicesModal(false)} className="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors font-medium">Close
-
-
-
- )}
-
- );
-};
-
-export default SettingsPage;
diff --git a/frontend/src/pages/SkillsPage.js b/frontend/src/pages/SkillsPage.js
deleted file mode 100644
index d49aa29..0000000
--- a/frontend/src/pages/SkillsPage.js
+++ /dev/null
@@ -1,557 +0,0 @@
-import React, { useState, useEffect, useMemo } from 'react';
-import ReactMarkdown from 'react-markdown';
-import { getSkills, createSkill, updateSkill, deleteSkill } from '../services/apiService';
-
-export default function SkillsPage({ user, Icon }) {
- const [skills, setSkills] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [searchQuery, setSearchQuery] = useState('');
- const [activeFilter, setActiveFilter] = useState('all'); // all, system, mine, group
- const [viewingDoc, setViewingDoc] = useState(null);
- const [showRawDoc, setShowRawDoc] = useState(false);
-
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [editingSkill, setEditingSkill] = useState(null);
-
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- skill_type: 'local',
- config: '{}',
- is_system: false,
- system_prompt: '',
- is_enabled: true,
- features: ['chat'],
- extra_metadata: { emoji: 'โ๏ธ' },
- preview_markdown: ''
- });
- const [showAdvanced, setShowAdvanced] = useState(false);
-
- const fetchSkills = async () => {
- try {
- setLoading(true);
- const data = await getSkills();
- setSkills(data);
- setError(null);
- } catch (err) {
- setError("Failed to load skills.");
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchSkills();
- }, []);
-
- const filteredSkills = useMemo(() => {
- return skills.filter(skill => {
- const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
- (skill.description || '').toLowerCase().includes(searchQuery.toLowerCase());
-
- if (!matchesSearch) return false;
- if (activeFilter === 'all') return true;
- if (activeFilter === 'system') return skill.is_system;
- if (activeFilter === 'mine') return !skill.is_system && skill.owner_id === user?.id;
- if (activeFilter === 'group') return skill.group_id && !skill.is_system;
- return true;
- });
- }, [skills, searchQuery, activeFilter, user]);
-
- const stats = useMemo(() => ({
- total: skills.length,
- system: skills.filter(s => s.is_system).length,
- mine: skills.filter(s => !s.is_system && s.owner_id === user?.id).length,
- enabled: skills.filter(s => s.is_enabled).length
- }), [skills, user]);
-
- const openModal = (skill = null) => {
- if (skill) {
- setEditingSkill(skill);
- setFormData({
- name: skill.name,
- description: skill.description || '',
- skill_type: skill.skill_type,
- config: JSON.stringify(skill.config, null, 2),
- is_system: skill.is_system,
- system_prompt: skill.system_prompt || '',
- is_enabled: skill.is_enabled ?? true,
- features: skill.features || ['chat'],
- extra_metadata: skill.extra_metadata || { emoji: 'โ๏ธ' },
- preview_markdown: skill.preview_markdown || ''
- });
- } else {
- setEditingSkill(null);
- setFormData({
- name: '',
- description: '',
- skill_type: 'local',
- config: '{}',
- is_system: false,
- system_prompt: '',
- is_enabled: true,
- features: ['chat'],
- extra_metadata: { emoji: 'โ๏ธ' },
- preview_markdown: ''
- });
- }
- setIsModalOpen(true);
- };
-
- const handleClone = (skill) => {
- setEditingSkill(null);
- setFormData({
- name: `${skill.name}_clone`,
- description: skill.description || '',
- skill_type: skill.skill_type,
- config: JSON.stringify(skill.config, null, 2),
- is_system: false,
- system_prompt: skill.system_prompt || '',
- is_enabled: true,
- features: skill.features || ['chat'],
- extra_metadata: skill.extra_metadata || { emoji: 'โ๏ธ' },
- preview_markdown: skill.preview_markdown || ''
- });
- setIsModalOpen(true);
- };
-
- const closeModal = () => {
- setIsModalOpen(false);
- setEditingSkill(null);
- setViewingDoc(null);
- setShowRawDoc(false);
- };
-
- const handleSave = async () => {
- try {
- let configObj = {};
- try {
- configObj = JSON.parse(formData.config);
- } catch (e) {
- alert("Invalid JSON in config");
- return;
- }
-
- const payload = {
- ...formData,
- config: configObj
- };
-
- if (editingSkill) {
- await updateSkill(editingSkill.id, payload);
- } else {
- await createSkill(payload);
- }
- closeModal();
- fetchSkills();
- } catch (err) {
- alert("Error saving skill");
- }
- };
-
- const handleDelete = async (id) => {
- if (!window.confirm("Are you sure you want to delete this skill?")) return;
- try {
- await deleteSkill(id);
- fetchSkills();
- } catch (err) {
- alert("Error deleting skill");
- }
- };
-
- const isAdmin = user?.role === 'admin';
-
- // --- Components ---
-
- const SidebarItem = ({ id, label, icon, count, active }) => (
-
setActiveFilter(id)}
- className={`w-full flex items-center px-4 py-3 rounded-xl transition-all duration-200 ${active
- ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-200 dark:shadow-indigo-900/20'
- : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
- }`}
- >
-
- {label}
-
- {count}
-
-
- );
-
- return (
-
- {/* --- Sidebar --- */}
-
-
-
- Cortex Skills
-
-
Foundational Layer
-
-
-
-
-
-
- s.group_id).length} active={activeFilter === 'group'} />
-
-
-
- openModal()}
- className="w-full bg-indigo-600 hover:bg-indigo-700 text-white rounded-[20px] py-4 font-black uppercase text-xs tracking-widest shadow-xl shadow-indigo-600/20 active:scale-95 transition-all flex items-center justify-center gap-2"
- >
-
- Create
-
-
-
-
- {/* --- Main Content --- */}
-
- {/* --- Top Navbar --- */}
-
-
-
- setSearchQuery(e.target.value)}
- className="w-full bg-gray-100/50 dark:bg-gray-700/50 border-none rounded-2xl py-3 pl-12 pr-4 focus:ring-2 focus:ring-indigo-500 outline-none transition-all placeholder-gray-400 text-sm"
- />
-
-
-
- {/* --- Skill Grid --- */}
-
- {loading ? (
-
- ) : error ? (
-
-
-
-
-
Access Denied
-
{error}
-
- ) : (
-
- {filteredSkills.map((skill) => (
-
-
-
- {skill.extra_metadata?.emoji || "โ๏ธ"}
-
-
-
- {skill.name}
-
-
- {skill.is_system && (
- Core
- )}
- {skill.skill_type}
-
-
-
-
- {skill.description || "No manifesto defined for this skill."}
-
-
-
- {(skill.features || []).map(f => (
-
- {f}
-
- ))}
-
-
-
-
- {(isAdmin || skill.owner_id === user?.id) && !skill.is_system && (
- openModal(skill)} className="p-2 text-gray-400 hover:text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-xl transition-all" title="Edit Logic">
-
-
- )}
- handleClone(skill)} className="p-2 text-gray-400 hover:text-green-500 hover:bg-green-50 dark:hover:bg-green-900/30 rounded-xl transition-all" title="Clone Pattern">
-
-
- {(isAdmin || skill.owner_id === user?.id) && !skill.is_system && (
- handleDelete(skill.id)} className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-green-900/30 rounded-xl transition-all" title="Burn (Delete)">
-
-
- )}
-
-
-
setViewingDoc(skill)}
- className="px-4 py-1.5 bg-gray-900 dark:bg-white text-white dark:text-gray-900 text-[10px] font-black uppercase rounded-xl hover:scale-105 active:scale-95 transition-all flex items-center gap-2 shadow-sm"
- >
- Docs
-
-
-
-
- ))}
-
- )}
-
-
-
- {/* --- Modals (Logic Edit / Docs View) --- */}
- {(isModalOpen || viewingDoc) && (
-
-
- {/* Modal Header */}
-
-
-
- {isModalOpen ? (formData.extra_metadata?.emoji || "โ๏ธ") : (viewingDoc?.extra_metadata?.emoji || "๐")}
-
-
-
- {isModalOpen
- ? (editingSkill ? `Engineering: ${formData.name}` : "Create New Pattern")
- : `Documentation: ${viewingDoc?.name}`
- }
-
-
-
- Skill Library Protocol
-
-
- {isModalOpen && (
-
setShowAdvanced(!showAdvanced)}
- className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-md border transition-all ${showAdvanced ? 'bg-indigo-500 border-indigo-500 text-white' : 'text-gray-400 border-gray-200 dark:border-gray-700'}`}
- >
- {showAdvanced ? "Engineering Mode: ON" : "Engineering Mode: OFF"}
-
- )}
-
-
-
-
-
-
-
-
- {/* Modal Body */}
-
- {isModalOpen ? (
-
- {/* Left Side: Metadata */}
-
-
-
-
- The manifesto (Description)
-
-
-
-
- Protocol Type
- setFormData({ ...formData, skill_type: e.target.value })}
- className="w-full bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3 text-sm font-bold focus:ring-2 focus:ring-indigo-500 outline-none appearance-none"
- >
- Native
- MCP
- gRPC
-
-
-
-
Capabilities
-
- {['chat', 'voice', 'workflow'].map(f => (
- {
- const newFeatures = formData.features.includes(f)
- ? formData.features.filter(x => x !== f)
- : [...formData.features, f];
- setFormData({ ...formData, features: newFeatures });
- }}
- className={`px-3 py-2 rounded-xl text-[10px] font-black uppercase border transition-all ${formData.features.includes(f)
- ? 'bg-indigo-600 border-indigo-600 text-white shadow-md'
- : 'bg-white dark:bg-transparent border-gray-200 dark:border-gray-700 text-gray-400'
- }`}
- >
- {f}
-
- ))}
-
-
-
-
-
-
-
- {/* Right Side: Logics */}
-
-
- Public Documentation (Markdown)
-
-
- {showAdvanced && (
-
-
- Intelligence Protocol (Prompt - Invisible to Users)
-
-
- Network Config (JSON)
-
-
- )}
-
-
- ) : (
-
-
-
Manifesto Overview
-
- {viewingDoc.description}
-
-
-
- {viewingDoc.preview_markdown && (
-
-
-
- {showRawDoc ? "Intelligence Blueprint (Raw)" : "Operational Documentation"}
-
- setShowRawDoc(!showRawDoc)}
- className="text-[10px] font-black uppercase text-indigo-500 hover:text-indigo-600 dark:text-indigo-400 hover:underline transition-all"
- >
- {showRawDoc ? "Show Preview" : "Raw Content"}
-
-
-
- {showRawDoc ? (
-
- {viewingDoc.preview_markdown}
-
- ) : (
-
- {viewingDoc.preview_markdown}
-
- )}
-
-
- )}
-
- {!viewingDoc.preview_markdown && (
-
-
-
-
-
This pattern does not include external documentation.
-
- )}
-
- )}
-
-
- {/* Modal Footer */}
-
-
- {viewingDoc ? 'Dismiss' : 'Cancel'}
-
- {isModalOpen && (
-
-
- {editingSkill ? 'Commit Changes' : 'Deploy Skill'}
-
- )}
- {viewingDoc && (isAdmin || viewingDoc.owner_id === user?.id) && !viewingDoc.is_system && (
- { closeModal(); openModal(viewingDoc); }}
- className="px-10 py-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-black uppercase rounded-[20px] shadow-xl shadow-indigo-600/20 hover:scale-105 active:scale-95 transition-all flex items-center gap-2"
- >
-
- Edit Logic
-
- )}
-
-
-
- )}
-
- );
-}
diff --git a/frontend/src/pages/SwarmControlPage.js b/frontend/src/pages/SwarmControlPage.js
deleted file mode 100644
index 0a10c22..0000000
--- a/frontend/src/pages/SwarmControlPage.js
+++ /dev/null
@@ -1,915 +0,0 @@
-import React, { useState, useRef, useEffect, useCallback } from "react";
-import { ChatArea } from "../features/chat";
-import { SessionSidebar, MultiNodeConsole, FileSystemNavigator } from "../shared/components";
-import useSwarmControl from "../hooks/useSwarmControl";
-import {
- updateSession, getSessionNodeStatus, attachNodesToSession,
- detachNodeFromSession, getUserAccessibleNodes, getUserNodePreferences, nodeFsList,
- clearSessionHistory
-} from "../services/apiService";
-
-const CodeAssistantPage = () => {
- const pageContainerRef = useRef(null);
-
- const onNewSessionCreated = useCallback(async (newSid) => {
- try {
- const [accessibleNodes, prefs] = await Promise.all([
- getUserAccessibleNodes(),
- getUserNodePreferences()
- ]);
-
- let targetIds = [];
- const defaultIds = prefs.default_node_ids || [];
-
- if (defaultIds.length > 0) {
- // Filter defaults by what is actually accessible
- const accessibleIds = accessibleNodes.map(n => n.node_id);
- targetIds = defaultIds.filter(id => accessibleIds.includes(id));
- } else {
- // Fallback: attach all accessible nodes if no defaults specified
- targetIds = accessibleNodes.map(n => n.node_id);
- }
-
- if (targetIds.length > 0) {
- const syncConfig = prefs.data_source || { source: 'server' };
- await attachNodesToSession(newSid, targetIds, syncConfig);
-
- // Immediate local sync for UI
- setAttachedNodeIds(targetIds);
- setSyncConfig(syncConfig); // Ensure UI matches the applied config
-
- // Refresh full status from server to stay in sync
- const status = await getSessionNodeStatus(newSid);
- const apiIds = (status.nodes || []).map(n => n.node_id);
- setAttachedNodeIds(apiIds);
- }
- } catch (e) {
- console.warn("M3: Failed auto attaching defaults on new session", e);
- }
- }, []);
-
- const {
- chatHistory,
- isProcessing,
- errorMessage,
- showErrorModal,
- tokenUsage,
- handleSendChat,
- handleCancelChat,
- setShowErrorModal,
- handleSwitchSession,
- sessionId,
- userConfigData,
- localActiveLLM,
- setLocalActiveLLM,
- isConfigured,
- missingConfigs
- } = useSwarmControl({ pageContainerRef, onNewSessionCreated });
-
- const [showConfigModal, setShowConfigModal] = useState(false);
- const [showClearChatModal, setShowClearChatModal] = useState(false);
- const [isClearingHistory, setIsClearingHistory] = useState(false);
-
- const confirmClearHistory = async () => {
- if (!sessionId) return;
- setIsClearingHistory(true);
- try {
- await clearSessionHistory(sessionId);
- // Reload the page to refresh chat history from the server
- window.location.reload();
- } catch (e) {
- alert(`Failed to clear history: ${e.message}`);
- } finally {
- setIsClearingHistory(false);
- setShowClearChatModal(false);
- }
- };
-
- const handleClearHistory = () => {
- if (!sessionId) return;
- setShowClearChatModal(true);
- };
-
- const [showNodeSelector, setShowNodeSelector] = useState(false);
- const isEditingMeshRef = useRef(false);
- useEffect(() => {
- isEditingMeshRef.current = showNodeSelector;
- }, [showNodeSelector]);
- const [sidebarRefreshTick, setSidebarRefreshTick] = useState(0);
-
- // M3/M6 Node Integration State
- const [sessionNodeStatus, setSessionNodeStatus] = useState({}); // node_id -> { status, last_sync }
- const [accessibleNodes, setAccessibleNodes] = useState([]);
- const [attachedNodeIds, setAttachedNodeIds] = useState([]);
- const [workspaceId, setWorkspaceId] = useState("");
- const [showConsole, setShowConsole] = useState(false);
- const [syncConfig, setSyncConfig] = useState({ source: 'server', path: '', source_node_id: '', read_only_node_ids: [] });
- const [activeSyncConfig, setActiveSyncConfig] = useState(null);
- const [pathSuggestions, setPathSuggestions] = useState([]);
- const [isSearchingPath, setIsSearchingPath] = useState(false);
- const [showPathSuggestions, setShowPathSuggestions] = useState(false);
- const [hasLoadedDefaults, setHasLoadedDefaults] = useState(false);
- const [isInitiatingSync, setIsInitiatingSync] = useState(false);
- const [showFileExplorer, setShowFileExplorer] = useState(false);
- const [isConsoleExpanded, setIsConsoleExpanded] = useState(false);
- const [consoleHeight, setConsoleHeight] = useState(256); // Default 64 * 4px = 256px
- const [isDraggingConsole, setIsDraggingConsole] = useState(false);
- const isDraggingConsoleRef = useRef(false);
-
- // Persistence for Auto-Collapse
- const [autoCollapse, setAutoCollapse] = useState(() => {
- return localStorage.getItem("swarm_auto_collapse") === "true";
- });
-
- const toggleAutoCollapse = () => {
- const newState = !autoCollapse;
- setAutoCollapse(newState);
- localStorage.setItem("swarm_auto_collapse", newState);
- };
-
- // Handle Dragging Console Resizer explicitly
- useEffect(() => {
- const handleMouseMove = (e) => {
- if (!isDraggingConsoleRef.current) return;
- e.preventDefault();
- const newHeight = window.innerHeight - e.clientY;
- const clampedHeight = Math.max(100, Math.min(window.innerHeight * 0.9, newHeight));
- setConsoleHeight(clampedHeight);
- };
-
- const handleMouseUp = () => {
- if (isDraggingConsoleRef.current) {
- isDraggingConsoleRef.current = false;
- setIsDraggingConsole(false);
- document.body.style.cursor = 'default';
- // Auto-fit xterm when dragged
- window.dispatchEvent(new Event('resize'));
- }
- };
-
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mouseup', handleMouseUp);
-
- return () => {
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mouseup', handleMouseUp);
- };
- }, []);
-
- const handleConsoleDragStart = (e) => {
- e.preventDefault(); // Prevents text selection while dragging
- e.stopPropagation();
- isDraggingConsoleRef.current = true;
- setIsDraggingConsole(true);
- document.body.style.cursor = 'row-resize';
- };
-
- // M6: Persistence - if we have an active config, populate the form with it when modal opens
- useEffect(() => {
- if (showNodeSelector && activeSyncConfig) {
- setSyncConfig({
- source: activeSyncConfig.source || 'server',
- path: activeSyncConfig.path || '',
- source_node_id: activeSyncConfig.source_node_id || '',
- read_only_node_ids: activeSyncConfig.read_only_node_ids || []
- });
- }
- }, [showNodeSelector, activeSyncConfig]);
-
- // Auto-enforce Receiver Only logic in UI
- useEffect(() => {
- if (syncConfig.source === 'server') {
- setSyncConfig(prev => ({ ...prev, read_only_node_ids: [...attachedNodeIds] }));
- } else if (syncConfig.source === 'node_local' && syncConfig.source_node_id) {
- const others = attachedNodeIds.filter(id => id !== syncConfig.source_node_id);
- setSyncConfig(prev => ({ ...prev, read_only_node_ids: others }));
- }
- }, [syncConfig.source, syncConfig.source_node_id, attachedNodeIds]);
-
- const handleInitiateSync = async () => {
- if (!sessionId) return;
- setIsInitiatingSync(true);
- try {
- await attachNodesToSession(sessionId, attachedNodeIds, syncConfig);
- // M3: Explicitly turn off the editing mesh ref and modal BEFORE fetching
- // so the server's newly saved attached nodes replace the user checkboxes immediately
- isEditingMeshRef.current = false;
- setShowNodeSelector(false);
-
- await fetchNodeInfo();
- } catch (err) {
- alert(`Sync Error: ${err.message}`);
- } finally {
- setIsInitiatingSync(false);
- }
- };
-
- const fetchNodeInfo = async () => {
- if (!sessionId) return;
- try {
- const [status, nodes] = await Promise.all([
- getSessionNodeStatus(sessionId),
- getUserAccessibleNodes()
- ]);
- const apiNodes = status.nodes || [];
- const apiIds = apiNodes.map(n => n.node_id);
-
- // Sanitized ID List: only show IDs that are actually in the live accessible list
- const liveIds = nodes.map(n => n.node_id);
- const sanitizedIds = apiIds.filter(id => liveIds.includes(id));
-
- const syncStatusMap = {};
- apiNodes.forEach(n => {
- syncStatusMap[n.node_id] = { status: n.status, last_sync: n.last_sync };
- });
-
- setSessionNodeStatus(syncStatusMap);
- setWorkspaceId(status.sync_workspace_id || "");
- setAccessibleNodes(nodes);
-
- // Stop auto-poll from wiping out user's active checkbox edits
- if (!isEditingMeshRef.current) {
- setAttachedNodeIds(sanitizedIds);
- setActiveSyncConfig(status.sync_config || null);
- }
- } catch (e) {
- console.warn("M3: Failed to fetch session node info", e);
- }
- };
-
- useEffect(() => {
- fetchNodeInfo();
- const interval = setInterval(fetchNodeInfo, 5000); // Polling status
- return () => clearInterval(interval);
- }, [sessionId]);
-
- // M3: Path Autocomplete Logic
- useEffect(() => {
- const fetchSuggestions = async () => {
- if (syncConfig.source !== 'node_local' || !syncConfig.source_node_id || !syncConfig.path) {
- setPathSuggestions([]);
- return;
- }
-
- const path = syncConfig.path;
- // Determine the directory to list and the fragment being typed
- let dirToList = ".";
- let fragment = "";
-
- if (path.includes('/')) {
- const lastSlashIndex = path.lastIndexOf('/');
- dirToList = path.substring(0, lastSlashIndex) || "/";
- fragment = path.substring(lastSlashIndex + 1);
- } else {
- fragment = path;
- }
-
- setIsSearchingPath(true);
- try {
- const results = await nodeFsList(syncConfig.source_node_id, dirToList);
- const fileList = results.files || [];
-
- // Filter: must be directory AND must start with the current fragment
- const dirs = fileList
- .filter(item => item.is_dir && item.name.toLowerCase().startsWith(fragment.toLowerCase()))
- .map(item => item.name);
-
- setPathSuggestions(dirs);
- } catch (e) {
- setPathSuggestions([]);
- } finally {
- setIsSearchingPath(false);
- }
- };
-
- const timer = setTimeout(fetchSuggestions, 500);
- return () => clearTimeout(timer);
- }, [syncConfig.path, syncConfig.source_node_id, syncConfig.source]);
-
- useEffect(() => {
- const loadDefaults = async () => {
- try {
- const prefs = await getUserNodePreferences();
- if (prefs && prefs.data_source && !hasLoadedDefaults) {
- setSyncConfig(prefs.data_source);
- setHasLoadedDefaults(true);
- }
- } catch (e) {
- console.warn("M3: Failed to load node defaults", e);
- }
- };
- loadDefaults();
- }, [hasLoadedDefaults]);
-
- const handleToggleNode = (nodeId, isCurrentlyAttached) => {
- if (isCurrentlyAttached) {
- setAttachedNodeIds(prev => prev.filter(id => id !== nodeId));
- // Also remove from read-only if it's there
- setSyncConfig(prev => ({
- ...prev,
- read_only_node_ids: (prev.read_only_node_ids || []).filter(id => id !== nodeId)
- }));
- } else {
- setAttachedNodeIds(prev => [...prev, nodeId]);
- }
- };
-
- const handleToggleReadOnly = (nodeId) => {
- setSyncConfig(prev => {
- const current = prev.read_only_node_ids || [];
- if (current.includes(nodeId)) {
- return { ...prev, read_only_node_ids: current.filter(id => id !== nodeId) };
- } else {
- return { ...prev, read_only_node_ids: [...current, nodeId] };
- }
- });
- };
-
- const handleSaveQuickConfig = async () => {
- try {
- if (sessionId && localActiveLLM) {
- await updateSession(sessionId, { provider_name: localActiveLLM });
- setSidebarRefreshTick(t => t + 1);
- }
- setShowConfigModal(false);
- } catch (e) {
- console.error("Failed to update session configs:", e);
- }
- };
-
- useEffect(() => {
- if (pageContainerRef.current) {
- pageContainerRef.current.scrollTop = pageContainerRef.current.scrollHeight;
- }
- }, [chatHistory]);
-
-
- return (
-
- {/* Invisible overlay to catch events across the entire screen during fast drag */}
- {isDraggingConsole && (
-
- )}
-
-
handleSendChat("/new")}
- refreshTick={sidebarRefreshTick}
- />
-
- {/* Main content area */}
-
-
- {/* Chat Area & Header */}
-
-
-
-
-
-
- Swarm Control
-
- Mesh: {accessibleNodes.filter(n => n.last_status === 'online' || n.last_status === 'idle').length} Online / {accessibleNodes.length} Total
-
-
-
- {/* Nodes Indicator Bar (M3/M6) */}
-
- {attachedNodeIds.length === 0 ? (
-
setShowNodeSelector(true)}
- >
- {accessibleNodes.length === 0 ? 'No nodes found in mesh' : 'Click to attach nodes'}
-
- ) : (
-
setShowNodeSelector(true)}>
-
- {accessibleNodes.filter(n => attachedNodeIds.includes(n.node_id)).slice(0, 3).map((node, i) => {
- const isOnline = node.last_status === 'online' || node.last_status === 'idle' || node.last_status === 'busy';
- return (
-
- {node.display_name.charAt(0).toUpperCase()}
-
- )
- })}
- {attachedNodeIds.length > 3 && (
-
- +{attachedNodeIds.length - 3}
-
- )}
-
-
-
- {attachedNodeIds.length} Attached
-
-
- {(workspaceId || syncConfig.source !== 'empty') && (
-
-
-
- File Sync Active
-
- Workspace: {workspaceId || 'Initializing...'}
-
-
-
- )}
-
-
- Click to manage mesh strategy
-
-
- )}
- {attachedNodeIds.length > 0 && (
-
-
setShowConsole(!showConsole)}
- className={`p-1.5 rounded-lg transition-colors ${showConsole ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-50'}`}
- title="Toggle Execution Console"
- >
-
-
-
-
- {workspaceId && (
-
setShowFileExplorer(!showFileExplorer)}
- className={`p-1.5 rounded-lg transition-colors ${showFileExplorer ? 'bg-indigo-100 text-indigo-600' : 'text-gray-400 hover:bg-gray-50'}`}
- title="Toggle File Explorer"
- >
-
-
-
-
- )}
-
- )}
-
-
- {!isConfigured && (
-
-
-
-
-
-
Missing Key
-
- {missingConfigs?.map((m, i) => {m} )}
-
-
-
- )}
-
setShowConfigModal(true)}
- className="text-gray-400 hover:text-indigo-600 transition-colors"
- >
-
-
-
-
-
-
-
-
- Auto-Collapse
-
-
- Conversational focus mode
- Collapses previous AI steps when finished
-
-
-
-
-
- Token Usage
-
-
-
-
80 ? 'bg-red-500' : 'bg-indigo-500'}`}
- style={{ width: `${Math.min(tokenUsage?.percentage || 0, 100)}%` }}
- >
-
-
80 ? 'text-red-500' : 'text-gray-400'}`}>
- {tokenUsage?.percentage || 0}%
-
-
-
-
-
-
- {isClearingHistory ? '...' : 'Clear Chat'}
-
-
- Clear Chat History
- Nodes & workspace sync are preserved
-
-
-
handleSendChat("/new")}
- className="text-xs font-bold px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-all shadow-md hover:shadow-indigo-500/20 active:scale-95"
- >
- + NEW
-
-
-
-
-
- {
- const sourceNode = accessibleNodes.find(n => n.node_id === activeSyncConfig.source_node_id);
- return !sourceNode || (sourceNode.last_status !== 'online' && sourceNode.last_status !== 'idle');
- })()
- }
- />
-
-
-
- {/* Swarm Execution Console (M6) - Responsive Overlay on small screens */}
- {showConsole && attachedNodeIds.length > 0 && (
-
- {!isConsoleExpanded && (
-
- )}
-
setIsConsoleExpanded(!isConsoleExpanded)}
- />
-
- )}
-
-
-
-
- {/* Workspace File Explorer (M6) - Responsive Overlay */}
- {showFileExplorer && workspaceId && attachedNodeIds.length > 0 && (
- <>
- {/* Backdrop for mobile */}
- setShowFileExplorer(false)}
- />
-
-
-
File Explorer
-
setShowFileExplorer(false)} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md">
-
-
-
-
-
- >
- )}
-
- {/* Node Selector Modal (M3) */}
- {showNodeSelector && (
-
-
-
-
-
- Mesh Node Selection
-
-
setShowNodeSelector(false)} className="text-gray-400 hover:text-gray-600">
-
-
-
-
-
- Select agent nodes to attach to this session. Click "Save & Sync" below to apply your changes. Attached nodes share the workspace {workspaceId} .
-
-
-
-
Initialization Strategy
-
-
- {syncConfig.source === 'node_local' && (
-
-
- Source Node
- setSyncConfig({ ...syncConfig, source_node_id: e.target.value })}
- >
- Select a source node...
- {accessibleNodes.filter(n => n.last_status === 'online' || n.last_status === 'idle').map(n => (
- {n.display_name} ({n.node_id})
- ))}
-
-
-
-
-
- Local Absolute Path
- {isSearchingPath && Searching... }
-
-
{
- setSyncConfig({ ...syncConfig, path: e.target.value });
- setShowPathSuggestions(true);
- }}
- onFocus={() => setShowPathSuggestions(true)}
- onBlur={() => setTimeout(() => setShowPathSuggestions(false), 200)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && pathSuggestions.length > 0 && showPathSuggestions) {
- e.preventDefault();
- const topSuggestion = pathSuggestions[0];
- const lastSlash = syncConfig.path.lastIndexOf('/');
- const base = lastSlash >= 0 ? syncConfig.path.substring(0, lastSlash + 1) : "";
- setSyncConfig({ ...syncConfig, path: `${base}${topSuggestion}/` });
- setPathSuggestions([]);
- }
- }}
- />
-
- {showPathSuggestions && pathSuggestions.length > 0 && (
-
- {pathSuggestions.map((suggestion, idx) => (
-
{
- const lastSlash = syncConfig.path.lastIndexOf('/');
- const base = lastSlash >= 0 ? syncConfig.path.substring(0, lastSlash + 1) : "";
- setSyncConfig({ ...syncConfig, path: `${base}${suggestion}/` });
- setPathSuggestions([]);
- }}
- >
- ๐ {suggestion}/
-
- ))}
-
- )}
-
-
- )}
-
-
-
- {accessibleNodes.length === 0 &&
No nodes available for your account. }
- {accessibleNodes.map(node => {
- const isAttached = attachedNodeIds.includes(node.node_id);
- const isOnline = node.last_status === 'online' || node.last_status === 'idle';
-
- return (
-
-
-
-
-
{node.display_name}
-
{node.node_id}
-
-
-
- {isAttached && (
-
handleToggleReadOnly(node.node_id)}
- className={`px-2 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-tighter transition-all flex items-center gap-1 ${syncConfig.read_only_node_ids?.includes(node.node_id) ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 border border-amber-200 dark:border-amber-800' : 'bg-gray-50 dark:bg-gray-700/50 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'}`}
- title={syncConfig.read_only_node_ids?.includes(node.node_id) ? "Receiver only mode active" : "Enable receiver only mode"}
- >
-
-
-
- {syncConfig.read_only_node_ids?.includes(node.node_id) ? 'Receiver Only' : 'Full Sync'}
-
- )}
-
handleToggleNode(node.node_id, isAttached)}
- disabled={!isOnline && !isAttached}
- className={`px-4 py-1.5 rounded-lg text-xs font-bold transition-all ${isAttached
- ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
- : isOnline
- ? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-indigo-500 hover:text-white'
- : 'bg-gray-50 dark:bg-gray-800 text-gray-300 dark:text-gray-600 cursor-not-allowed grayscale'
- }`}
- title={!isOnline && !isAttached ? "Node is currently offline" : ""}
- >
- {isAttached ? 'DETACH' : 'ATTACH'}
-
-
-
- );
- })}
-
-
-
-
- {isInitiatingSync ? (
- <>
-
- Initiating...
- >
- ) : 'Save & Sync'}
-
-
{
- setShowNodeSelector(false);
- isEditingMeshRef.current = false;
- fetchNodeInfo(); // Revert any unsaved toggle states instantly
- }}
- className="bg-gray-900 dark:bg-white dark:text-gray-900 text-white px-6 py-2 rounded-xl text-sm font-bold shadow-lg shadow-gray-900/20 active:scale-95 transition-all"
- >
- Cancel
-
-
-
-
- )}
-
- {/* Error Modal */}
- {showErrorModal && (
-
-
-
-
Attention Required
-
{errorMessage}
-
setShowErrorModal(false)}
- className="w-full bg-gray-900 dark:bg-white dark:text-gray-900 text-white font-bold py-3 rounded-xl transition-all active:scale-95"
- >
- Understand
-
-
-
- )}
-
- {showConfigModal && (
-
-
-
-
-
- Session Engine
-
-
setShowConfigModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-2">
-
-
-
-
-
- Active LLM Provider
- setLocalActiveLLM(e.target.value)}
- className="w-full border-2 border-gray-100 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-800 text-sm font-medium focus:border-indigo-500 focus:ring-0 transition-all outline-none"
- >
- -- Choose Provider --
- {userConfigData?.effective?.llm?.providers && Object.keys(userConfigData.effective.llm.providers).map(pid => {
- const modelName = userConfigData.effective.llm.providers[pid].model;
- return (
-
- {pid} {modelName ? `(${modelName})` : ''}
-
- );
- })}
-
-
-
-
- setShowConfigModal(false)}
- className="px-6 py-2.5 text-sm font-bold text-gray-500 hover:text-gray-800 transition-colors"
- >
- Cancel
-
-
- Apply Changes
-
-
-
-
- )}
-
- {/* Clear Chat Confirmation Modal */}
- {showClearChatModal && (
-
-
-
-
Clear Chat History?
-
- This will permanently delete all messages in this session. Your attached nodes, workspace files, and mesh configurations will be preserved .
-
-
-
setShowClearChatModal(false)}
- className="flex-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white font-bold py-3 rounded-xl transition-all active:scale-95"
- >
- Cancel
-
-
- {isClearingHistory ? (
- <>
-
- Clearing...
- >
- ) : 'Clear Chat'}
-
-
-
-
- )}
-
- );
-};
-
-export default CodeAssistantPage;
\ No newline at end of file