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 ( +
+ + + + + Processing login... +
+ ); + } + + if (error) { + return ( +
+

Error:

+

{error}

+
+ ); + } + + if (user) { + return ( +
+

Login Successful!

+

Welcome, {user.email}.

+

User ID: {user.id}

+ +
+ ); + } + + return ( + <> +

Login

+

+ Click the button below to log in using OpenID Connect (OIDC). +

+ + + ); + }; + + return ( +
+
+ {renderContent()} +
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/src/features/chat/index.js b/frontend/src/features/chat/index.js index bf817ea..218d7b8 100644 --- a/frontend/src/features/chat/index.js +++ b/frontend/src/features/chat/index.js @@ -3,3 +3,4 @@ export { default as ChatArea } from "./components/ChatArea"; export { default as ChatWindow } from "./components/ChatWindow"; +export { default as HomePage } from "./pages/HomePage"; diff --git a/frontend/src/features/chat/pages/HomePage.js b/frontend/src/features/chat/pages/HomePage.js new file mode 100644 index 0000000..81e7fed --- /dev/null +++ b/frontend/src/features/chat/pages/HomePage.js @@ -0,0 +1,71 @@ +// 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. +

+
+ +
+ + + {!isLoggedIn && ( + + )} +
+
+
+ ); +}; + +export default HomePage; \ No newline at end of file diff --git a/frontend/src/features/profile/index.js b/frontend/src/features/profile/index.js new file mode 100644 index 0000000..62d66bc --- /dev/null +++ b/frontend/src/features/profile/index.js @@ -0,0 +1 @@ +export { default as ProfilePage } from './pages/ProfilePage'; diff --git a/frontend/src/features/profile/pages/ProfilePage.js b/frontend/src/features/profile/pages/ProfilePage.js new file mode 100644 index 0000000..69212d2 --- /dev/null +++ b/frontend/src/features/profile/pages/ProfilePage.js @@ -0,0 +1,382 @@ +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 ( +
+
Loading identity...
+
+ ); + } + + 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 ? Avatar : 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 && ( + + )} +
+
+ + {message.text && ( +
+ {message.text} +
+ )} + +
+ {/* General Information */} +
+

+ + General Information +

+
+
+ + setEditData({ ...editData, full_name: e.target.value })} + placeholder="Enter your full name" + /> +
+
+ + setEditData({ ...editData, username: e.target.value })} + placeholder="Display name" + /> +
+
+ +

+ {profile.group_name || 'Ungrouped'} +

+

Groups are managed by your administrator.

+
+
+ +
+
+
+ + {/* Service Preferences */} +
+

+ + Service Preferences +

+
+ + + +
+ + {/* Node Defaults Section */} + {accessibleNodes.length > 0 && ( +
+

+ + Default Node Attachment +

+
+ +
+ {accessibleNodes.map(node => { + const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); + return ( + + ); + })} +
+
+ +
+ +
+ + {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. +

+
+
+ )} + + +
+ Status: +
+
+ Verified +
+
+
+ Failed +
+
+
+ Untested +
+
+

+ 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 ( +
+ +
+ {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 ( + + ); + }) + )} +
+
+ ); +}; + +export default ProfilePage; diff --git a/frontend/src/features/settings/components/SettingsPageContent.js b/frontend/src/features/settings/components/SettingsPageContent.js new file mode 100644 index 0000000..5116b4d --- /dev/null +++ b/frontend/src/features/settings/components/SettingsPageContent.js @@ -0,0 +1,627 @@ +import React from 'react'; +import VoicesModal from './components/VoicesModal'; + +export default function SettingsPageContent({ context }) { + const { + config, + effective, + loading, + saving, + message, + activeConfigTab, + setActiveConfigTab, + activeAdminTab, + setActiveAdminTab, + userSearch, + setUserSearch, + expandedProvider, + setExpandedProvider, + selectedNewProvider, + setSelectedNewProvider, + verifying, + setVerifying, + fetchedModels, + setFetchedModels, + providerLists, + providerStatuses, + voiceList, + showVoicesModal, + setShowVoicesModal, + voicesLoading, + allUsers, + usersLoading, + loadUsers, + allGroups, + groupsLoading, + editingGroup, + setEditingGroup, + addingSection, + setAddingSection, + addForm, + setAddForm, + allNodes, + nodesLoading, + allSkills, + skillsLoading, + accessibleNodes, + nodePrefs, + fileInputRef, + handleViewVoices, + handleRoleToggle, + handleGroupChange, + handleNodePrefChange, + toggleDefaultNode, + handleSaveGroup, + handleDeleteGroup, + handleSave, + handleImport, + handleExport, + inputClass, + labelClass, + sectionClass, + filteredUsers, + sortedGroups, + renderProviderSection + } = context; + + return ( + <> +
+
+
+

Configuration

+
+ + + +
+
+

+ 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) => ( + + ))} +
+ +
+ {/* LLM Settings */} + {activeConfigTab === 'llm' && ( +
+ {renderProviderSection('llm', providerLists.llm, false)} +
+ )} + + {/* TTS Settings */} + {activeConfigTab === 'tts' && ( +
+ {renderProviderSection('tts', providerLists.tts, true)} +
+ )} + + {/* STT Settings */} + {activeConfigTab === 'stt' && ( +
+ {renderProviderSection('stt', providerLists.stt, false)} +
+ )} + +
+ +
+
+
+ + {/* Card 2: Team & Access Management */} +
+
+

+ + Identity & Access Governance +

+

Define groups, policies, and manage members

+
+ + {/* Admin Tabs */} +
+ {['groups', 'users', 'personal'].map((tab) => ( + + ))} +
+ +
+ {/* Groups Management */} + {activeAdminTab === 'groups' && ( +
+ {!editingGroup ? ( +
+
+

+ Registered Groups +

+ +
+ +
+ {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 === 'nodes' ? 'Accessible Nodes' : `${section} Access`} +
+ {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} +
+ )} +
+
+ ))} +
+
+
+ + {g.id !== 'ungrouped' && ( + + )} +
+
+ ))} +
+
+ ) : ( +
+ {/* (Group editing form - unchanged logic, just cleaner container) */} +
+ +

+ {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`} +

+ {editingGroup.id === 'ungrouped' && ( + + + System Group + + )} +
+ +
+
+
+ + 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 +

+ )} +
+
+ + setEditingGroup({ ...editingGroup, description: e.target.value })} + placeholder="Short description of this group..." + className={inputClass} + /> +
+
+ +
+ + +
+ {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( +
+
+ {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} +
+ + +
+
+
+ {(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 ( + + ); + })} +
+ {section === 'nodes' && allNodes.length === 0 && ( +

No agent nodes registered yet.

+ )} +
+ ))} +
+ +
+ + +
+
+
+
+ )} +
+ )} + + {/* 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" + /> + +
+ +
+
+
+ + + + + + + + + + + {filteredUsers.map((u) => ( + + + + + + + ))} + +
MemberPolicy GroupActivity AuditingActions
+
+
+ {(u.username || u.email || '?')[0].toUpperCase()} +
+
+

{u.username || u.email}

+

{u.role}

+
+
+
+ + +
+
+ Join: + {new Date(u.created_at).toLocaleDateString()} +
+
+ Last: + + {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} + +
+
+
+ +
+ {allUsers.length === 0 && !usersLoading && ( +
No other users found.
+ )} +
+
+
+ )} + + {/* Personal Settings */} + {activeAdminTab === 'personal' && ( +
+
+
+
+ +
+
+

My Preferences

+

Customize your individual experience

+
+
+ +
+ {accessibleNodes.length > 0 ? ( +
+ +

Auto-attach these nodes to new sessions:

+
+ {accessibleNodes.map(node => { + const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); + return ( + + ); + })} +
+
+ ) : ( +
+

No agent nodes are currently assigned to your group.

+
+ )} + +
+ +
+ + {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.

+
+ +
+
+ {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. + +
+
+
+ )} +
+
+
+
+ + ); +} diff --git a/frontend/src/features/settings/components/components/VoicesModal.js b/frontend/src/features/settings/components/components/VoicesModal.js new file mode 100644 index 0000000..8356b95 --- /dev/null +++ b/frontend/src/features/settings/components/components/VoicesModal.js @@ -0,0 +1,44 @@ +import React from 'react'; + +export default function VoicesModal({ show, onClose, voiceList, voicesLoading }) { + if (!show) return null; + + return ( +
+
e.stopPropagation()}> +
+
+

Available Cloud Voices

+

Found {voiceList.length} voices to choose from.

+

Highlighted voices (Chirp, Journey, Studio) use advanced AI for highest quality.

+
+ +
+
+ {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. + +
+
+
+ ); +} diff --git a/frontend/src/features/settings/index.js b/frontend/src/features/settings/index.js new file mode 100644 index 0000000..fa5462f --- /dev/null +++ b/frontend/src/features/settings/index.js @@ -0,0 +1 @@ +export { default as SettingsPage } from './pages/SettingsPage'; diff --git a/frontend/src/features/settings/pages/SettingsPage.js b/frontend/src/features/settings/pages/SettingsPage.js new file mode 100644 index 0000000..dac691d --- /dev/null +++ b/frontend/src/features/settings/pages/SettingsPage.js @@ -0,0 +1,1565 @@ +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'; +import SettingsPageContent from '../components/SettingsPageContent'; + +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 ( +
+
Loading settings...
+
+ ); + } + + 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 ? ( + + ) : ( +
+

New Provider Instance

+ + {/* Row 1: Type + Label suffix */} +
+
+ + +
+
+ + 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 */} +
+
+ + {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" + /> + )} +
+ {cloneableSources.length > 0 && ( +
+ + + {addForm.cloneFrom && ( +

โœ“ API key will be copied from "{addForm.cloneFrom}" on save

+ )} +
+ )} +
+ + {/* Action buttons */} +
+ + +
+
+ )} +
+ +
+ Status Legend: +
+
+ Verified +
+
+
+ Failed +
+
+
+ Not Tested +
+
+ +
+ {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} +
+ + + + + + +
+
+ {isExpanded && ( +
+
+
+
+ +
+ <> + { + 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') && ( +
+ + {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); + }} + 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 && ( +
+
+ + +
+ { + 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 */} +
+ + +
+ {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} +
+ +
+ ); + })} +
+ +
+
+ +
+
+ +
+ +
+
+ +
+
+

Access Control

+

Manage which groups can use this provider.

+
+ +
+
+ )} +
+ ); + })} +
+
+ ); + }; + + 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

+
+ + + +
+
+

+ 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) => ( + + ))} +
+ +
+ {/* LLM Settings */} + {activeConfigTab === 'llm' && ( +
+ {renderProviderSection('llm', providerLists.llm, false)} +
+ )} + + {/* TTS Settings */} + {activeConfigTab === 'tts' && ( +
+ {renderProviderSection('tts', providerLists.tts, true)} +
+ )} + + {/* STT Settings */} + {activeConfigTab === 'stt' && ( +
+ {renderProviderSection('stt', providerLists.stt, false)} +
+ )} + +
+ +
+
+
+ + {/* Card 2: Team & Access Management */} +
+
+

+ + Identity & Access Governance +

+

Define groups, policies, and manage members

+
+ + {/* Admin Tabs */} +
+ {['groups', 'users', 'personal'].map((tab) => ( + + ))} +
+ +
+ {/* Groups Management */} + {activeAdminTab === 'groups' && ( +
+ {!editingGroup ? ( +
+
+

+ Registered Groups +

+ +
+ +
+ {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} +
+ )} +
+
+ ))} +
+
+
+ + {g.id !== 'ungrouped' && ( + + )} +
+
+ ))} +
+
+ ) : ( +
+ {/* (Group editing form - unchanged logic, just cleaner container) */} +
+ +

+ {editingGroup.id === 'new' ? 'New Group Policy' : `Edit: ${editingGroup.id === 'ungrouped' ? 'Standard / Guest Policy' : editingGroup.name}`} +

+ {editingGroup.id === 'ungrouped' && ( + + + System Group + + )} +
+ +
+
+
+ + 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 +

+ )} +
+
+ + setEditingGroup({ ...editingGroup, description: e.target.value })} + placeholder="Short description of this group..." + className={inputClass} + /> +
+
+ +
+ + +
+ {['llm', 'tts', 'stt', 'nodes', 'skills'].map(section => ( +
+
+ {section === 'nodes' ? 'Accessible Nodes' : `${section} Access`} +
+ + +
+
+
+ {(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 ( + + ); + })} +
+ {section === 'nodes' && allNodes.length === 0 && ( +

No agent nodes registered yet.

+ )} +
+ ))} +
+
+ +
+ + +
+
+
+ )} +
+ )} + + {/* 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" + /> + +
+ +
+
+
+ + + + + + + + + + + {filteredUsers.map((u) => ( + + + + + + + ))} + +
MemberPolicy GroupActivity AuditingActions
+
+
+ {(u.username || u.email || '?')[0].toUpperCase()} +
+
+

{u.username || u.email}

+

{u.role}

+
+
+
+ + +
+
+ Join: + {new Date(u.created_at).toLocaleDateString()} +
+
+ Last: + + {u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} + +
+
+
+ +
+ {allUsers.length === 0 && !usersLoading && ( +
No other users found.
+ )} +
+
+
+ )} + + {/* Personal Settings */} + {activeAdminTab === 'personal' && ( +
+
+
+
+ +
+
+

My Preferences

+

Customize your individual experience

+
+
+ +
+ {accessibleNodes.length > 0 ? ( +
+ +

Auto-attach these nodes to new sessions:

+
+ {accessibleNodes.map(node => { + const isActive = (nodePrefs.default_node_ids || []).includes(node.node_id); + return ( + + ); + })} +
+
+ ) : ( +
+

No agent nodes are currently assigned to your group.

+
+ )} + +
+ +
+ + {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.

+
+ +
+
+ {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. + +
+
+
+ )} +
+ ); + + const context = { + config, + effective, + loading, + saving, + message, + activeConfigTab, + setActiveConfigTab, + activeAdminTab, + setActiveAdminTab, + userSearch, + setUserSearch, + expandedProvider, + setExpandedProvider, + selectedNewProvider, + setSelectedNewProvider, + verifying, + setVerifying, + fetchedModels, + setFetchedModels, + providerLists, + providerStatuses, + voiceList, + showVoicesModal, + setShowVoicesModal, + voicesLoading, + allUsers, + usersLoading, + loadUsers, + allGroups, + groupsLoading, + editingGroup, + setEditingGroup, + addingSection, + setAddingSection, + addForm, + setAddForm, + allNodes, + nodesLoading, + allSkills, + skillsLoading, + accessibleNodes, + nodePrefs, + fileInputRef, + handleViewVoices, + handleRoleToggle, + handleGroupChange, + handleNodePrefChange, + toggleDefaultNode, + handleSaveGroup, + handleDeleteGroup, + handleSave, + handleImport, + handleExport, + inputClass, + labelClass, + sectionClass, + filteredUsers, + sortedGroups, + renderProviderSection + }; + + return ; +}; + +export default SettingsPage; diff --git a/frontend/src/features/skills/components/SkillsPageContent.js b/frontend/src/features/skills/components/SkillsPageContent.js new file mode 100644 index 0000000..17c2332 --- /dev/null +++ b/frontend/src/features/skills/components/SkillsPageContent.js @@ -0,0 +1,424 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; + +export default function SkillsPageContent({ context }) { + const { + user, + Icon, + loading, + error, + searchQuery, + setSearchQuery, + activeFilter, + setActiveFilter, + viewingDoc, + setViewingDoc, + showRawDoc, + setShowRawDoc, + isModalOpen, + openModal, + closeModal, + handleClone, + handleSave, + handleDelete, + isAdmin, + filteredSkills, + stats, + editingSkill, + formData, + setFormData, + showAdvanced, + setShowAdvanced + } = context; + + const SidebarItem = ({ id, label, icon, count, active }) => ( + + ); + + return ( +
+ {/* --- Sidebar --- */} +
+
+

+ Cortex Skills +

+

Foundational Layer

+
+ +
+ + + + +
+ +
+ +
+
+ + {/* --- 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 ? ( +
+
+

Syncing

+
+ ) : 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 && ( + + )} + + {(isAdmin || skill.owner_id === user?.id) && !skill.is_system && ( + + )} +
+ + +
+
+ ))} +
+ )} +
+
+ + {/* --- 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 && ( + + )} +
+
+
+ +
+ + {/* Modal Body */} +
+ {isModalOpen ? ( +
+ {/* Left Side: Metadata */} +
+
+ +
+ setFormData({ ...formData, extra_metadata: { ...formData.extra_metadata, emoji: e.target.value } })} + className="col-span-1 bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-2xl p-3 text-center text-2xl focus:ring-2 focus:ring-indigo-500 outline-none" + placeholder="๐Ÿ› ๏ธ" + /> + setFormData({ ...formData, name: e.target.value })} + className="col-span-3 bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-2xl px-4 py-3 focus:ring-2 focus:ring-indigo-500 outline-none font-bold" + placeholder="skill_unique_id" + /> +
+
+ +
+ +