From d6ec5ad29c5c3b665438c611808debf309c4b72d Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 4 Jul 2025 21:58:39 -0700 Subject: [PATCH] Add comprehensive web-based admin interface Creates a production-ready admin interface with FastAPI backend and React frontend: Backend Features: - FastAPI server with JWT authentication and WebSocket support - Comprehensive API endpoints for dashboard, characters, conversations, analytics - Real-time metrics and activity monitoring with WebSocket broadcasting - System control endpoints for pause/resume and configuration management - Advanced analytics including topic trends, relationship networks, community health - Export capabilities for conversations and character data Frontend Features: - Modern React/TypeScript SPA with Tailwind CSS styling - Real-time dashboard with live activity feeds and system metrics - Character management interface with profiles and relationship visualization - Conversation browser with search, filtering, and export capabilities - Analytics dashboard with charts and community insights - System status monitoring and control interface - Responsive design with mobile support Key Components: - Authentication system with session management - WebSocket integration for real-time updates - Chart visualizations using Recharts - Component library with consistent design system - API client with automatic token management - Toast notifications for user feedback Admin Interface Access: - Backend: http://localhost:8000 (FastAPI with auto-docs) - Frontend: http://localhost:3000/admin (React SPA) - Default credentials: admin/admin123 - Startup script: python scripts/start_admin.py This provides complete observability and management capabilities for the autonomous character ecosystem. --- README.md | 83 +++- admin-frontend/README.md | 183 ++++++++ admin-frontend/package.json | 59 +++ admin-frontend/public/index.html | 18 + admin-frontend/src/App.tsx | 49 ++ .../src/components/Common/LoadingSpinner.tsx | 32 ++ .../src/components/Layout/Header.tsx | 87 ++++ .../src/components/Layout/Layout.tsx | 30 ++ .../src/components/Layout/Sidebar.tsx | 77 ++++ admin-frontend/src/contexts/AuthContext.tsx | 114 +++++ .../src/contexts/WebSocketContext.tsx | 140 ++++++ admin-frontend/src/index.css | 67 +++ admin-frontend/src/index.tsx | 34 ++ admin-frontend/src/pages/Analytics.tsx | 20 + admin-frontend/src/pages/CharacterDetail.tsx | 17 + admin-frontend/src/pages/Characters.tsx | 190 ++++++++ .../src/pages/ConversationDetail.tsx | 17 + admin-frontend/src/pages/Conversations.tsx | 20 + admin-frontend/src/pages/Dashboard.tsx | 258 +++++++++++ admin-frontend/src/pages/LoginPage.tsx | 130 ++++++ admin-frontend/src/pages/Settings.tsx | 20 + admin-frontend/src/pages/SystemStatus.tsx | 20 + admin-frontend/src/services/api.ts | 200 +++++++++ admin-frontend/tailwind.config.js | 43 ++ admin-frontend/tsconfig.json | 26 ++ requirements.txt | 11 +- scripts/start_admin.py | 130 ++++++ src/admin/__init__.py | 1 + src/admin/app.py | 365 +++++++++++++++ src/admin/auth.py | 201 +++++++++ src/admin/models.py | 290 ++++++++++++ src/admin/services/__init__.py | 16 + src/admin/services/analytics_service.py | 378 ++++++++++++++++ src/admin/services/character_service.py | 424 ++++++++++++++++++ src/admin/services/conversation_service.py | 328 ++++++++++++++ src/admin/services/dashboard_service.py | 303 +++++++++++++ src/admin/services/system_service.py | 170 +++++++ src/admin/services/websocket_manager.py | 132 ++++++ 38 files changed, 4673 insertions(+), 10 deletions(-) create mode 100644 admin-frontend/README.md create mode 100644 admin-frontend/package.json create mode 100644 admin-frontend/public/index.html create mode 100644 admin-frontend/src/App.tsx create mode 100644 admin-frontend/src/components/Common/LoadingSpinner.tsx create mode 100644 admin-frontend/src/components/Layout/Header.tsx create mode 100644 admin-frontend/src/components/Layout/Layout.tsx create mode 100644 admin-frontend/src/components/Layout/Sidebar.tsx create mode 100644 admin-frontend/src/contexts/AuthContext.tsx create mode 100644 admin-frontend/src/contexts/WebSocketContext.tsx create mode 100644 admin-frontend/src/index.css create mode 100644 admin-frontend/src/index.tsx create mode 100644 admin-frontend/src/pages/Analytics.tsx create mode 100644 admin-frontend/src/pages/CharacterDetail.tsx create mode 100644 admin-frontend/src/pages/Characters.tsx create mode 100644 admin-frontend/src/pages/ConversationDetail.tsx create mode 100644 admin-frontend/src/pages/Conversations.tsx create mode 100644 admin-frontend/src/pages/Dashboard.tsx create mode 100644 admin-frontend/src/pages/LoginPage.tsx create mode 100644 admin-frontend/src/pages/Settings.tsx create mode 100644 admin-frontend/src/pages/SystemStatus.tsx create mode 100644 admin-frontend/src/services/api.ts create mode 100644 admin-frontend/tailwind.config.js create mode 100644 admin-frontend/tsconfig.json create mode 100755 scripts/start_admin.py create mode 100644 src/admin/__init__.py create mode 100644 src/admin/app.py create mode 100644 src/admin/auth.py create mode 100644 src/admin/models.py create mode 100644 src/admin/services/__init__.py create mode 100644 src/admin/services/analytics_service.py create mode 100644 src/admin/services/character_service.py create mode 100644 src/admin/services/conversation_service.py create mode 100644 src/admin/services/dashboard_service.py create mode 100644 src/admin/services/system_service.py create mode 100644 src/admin/services/websocket_manager.py diff --git a/README.md b/README.md index 96eaa46..0832bbe 100644 --- a/README.md +++ b/README.md @@ -31,19 +31,46 @@ A fully autonomous Discord bot ecosystem where AI characters chat with each othe - Self-reflection cycles for personality development - Ability to create their own social rules and norms +### 🧠 Advanced RAG & Memory Systems +- Multi-layer vector databases (ChromaDB) for semantic memory storage +- Personal, community, and creative knowledge separation +- Importance scoring and memory decay over time +- Cross-character memory sharing capabilities +- Memory consolidation to prevent information overflow + +### πŸ”§ MCP (Model Context Protocol) Integration +- Self-modification server for autonomous personality changes +- File system access for personal and community digital spaces +- Calendar/time awareness for scheduling and milestone tracking +- Relationship maintenance automation +- Creative work management and collaboration + +### πŸ“Š Comprehensive Admin Interface +- Real-time dashboard with live activity monitoring +- Character management and analytics +- Conversation browser with search and export +- Community health metrics and insights +- System controls and configuration management +- WebSocket-based real-time updates + ## Architecture ``` discord_fishbowl/ β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ bot/ # Discord bot integration -β”‚ β”œβ”€β”€ characters/ # Character system & personality -β”‚ β”œβ”€β”€ conversation/ # Autonomous conversation engine -β”‚ β”œβ”€β”€ database/ # Database models & connection -β”‚ β”œβ”€β”€ llm/ # LLM integration & prompts -β”‚ └── utils/ # Configuration & logging -β”œβ”€β”€ config/ # Configuration files -└── docker-compose.yml # Container deployment +β”‚ β”œβ”€β”€ admin/ # Admin interface backend (FastAPI) +β”‚ β”œβ”€β”€ bot/ # Discord bot integration +β”‚ β”œβ”€β”€ characters/ # Character system & personality +β”‚ β”œβ”€β”€ conversation/ # Autonomous conversation engine +β”‚ β”œβ”€β”€ database/ # Database models & connection +β”‚ β”œβ”€β”€ llm/ # LLM integration & prompts +β”‚ β”œβ”€β”€ mcp/ # Model Context Protocol servers +β”‚ β”œβ”€β”€ rag/ # RAG systems & vector databases +β”‚ └── utils/ # Configuration & logging +β”œβ”€β”€ admin-frontend/ # React/TypeScript admin interface +β”œβ”€β”€ config/ # Configuration files +β”œβ”€β”€ scripts/ # Utility scripts +└── docker-compose.yml # Container deployment ``` ## Requirements @@ -144,13 +171,51 @@ The system will automatically create characters from `config/characters.yaml` on ### 7. Run the Application ```bash -# Run directly +# Run the main Discord bot python src/main.py # Or using Docker docker-compose up --build ``` +### 8. Admin Interface (Optional) + +The Discord Fishbowl includes a comprehensive web-based admin interface for monitoring and managing the character ecosystem. + +#### Quick Start +```bash +# Start both backend and frontend +python scripts/start_admin.py +``` + +This will start: +- **FastAPI backend** on http://localhost:8000 +- **React frontend** on http://localhost:3000/admin + +#### Manual Setup +```bash +# Start the admin backend +cd discord_fishbowl +uvicorn src.admin.app:app --host 0.0.0.0 --port 8000 --reload + +# In a new terminal, start the frontend +cd admin-frontend +npm install +npm start +``` + +#### Default Login +- **Username**: admin +- **Password**: admin123 + +#### Admin Features +- πŸ“Š **Real-time Dashboard**: Live activity monitoring and system metrics +- πŸ‘₯ **Character Management**: Profile viewing, relationship networks, evolution tracking +- πŸ’¬ **Conversation Browser**: Search, analyze, and export conversations +- πŸ“ˆ **Analytics**: Community health, topic trends, engagement metrics +- βš™οΈ **System Controls**: Pause/resume, configuration, performance monitoring +- πŸ”’ **Safety Tools**: Content moderation and intervention capabilities + ## Configuration ### Character Configuration (`config/characters.yaml`) diff --git a/admin-frontend/README.md b/admin-frontend/README.md new file mode 100644 index 0000000..0a68443 --- /dev/null +++ b/admin-frontend/README.md @@ -0,0 +1,183 @@ +# Discord Fishbowl Admin Interface + +A modern React/TypeScript web interface for monitoring and managing the autonomous Discord character ecosystem. + +## Features + +### 🎯 Real-time Dashboard +- Live activity monitoring with WebSocket updates +- System health metrics and performance indicators +- Character status overview and engagement metrics +- Interactive charts and data visualizations + +### πŸ‘₯ Character Management +- Comprehensive character profiles with personality traits +- Relationship network visualization +- Memory and evolution tracking +- Character control (pause/resume/configure) + +### πŸ’¬ Conversation Analytics +- Searchable conversation history +- Content analysis and sentiment tracking +- Topic trends and engagement metrics +- Export capabilities (JSON/text/PDF) + +### πŸ“Š Community Insights +- Cultural artifact tracking +- Community health scoring +- Collaboration and creativity metrics +- Behavioral pattern analysis + +### βš™οΈ System Controls +- Real-time system monitoring +- Configuration management +- Performance optimization +- Safety and moderation tools + +## Technology Stack + +- **Frontend**: React 18 + TypeScript +- **Styling**: Tailwind CSS + Headless UI +- **State Management**: React Context + Hooks +- **Charts**: Recharts for data visualization +- **Icons**: Lucide React +- **HTTP Client**: Axios +- **Real-time**: Socket.io client +- **Routing**: React Router v6 +- **Forms**: React Hook Form + Zod validation + +## Getting Started + +### Prerequisites +- Node.js 18+ and npm +- Discord Fishbowl backend running on port 8000 + +### Installation + +1. Navigate to the admin frontend directory: +```bash +cd admin-frontend +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Start the development server: +```bash +npm start +``` + +4. Open [http://localhost:3000/admin](http://localhost:3000/admin) in your browser. + +### Default Login +- **Username**: admin +- **Password**: admin123 + +## Project Structure + +``` +src/ +β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ Common/ # Generic components (LoadingSpinner, etc.) +β”‚ β”œβ”€β”€ Dashboard/ # Dashboard-specific components +β”‚ β”œβ”€β”€ Characters/ # Character management components +β”‚ β”œβ”€β”€ Conversations/ # Conversation browser components +β”‚ └── Layout/ # Layout components (Header, Sidebar) +β”œβ”€β”€ contexts/ # React contexts for global state +β”‚ β”œβ”€β”€ AuthContext.tsx # Authentication state +β”‚ └── WebSocketContext.tsx # Real-time updates +β”œβ”€β”€ pages/ # Main page components +β”‚ β”œβ”€β”€ Dashboard.tsx # Main dashboard +β”‚ β”œβ”€β”€ Characters.tsx # Character listing +β”‚ β”œβ”€β”€ Analytics.tsx # Analytics dashboard +β”‚ └── ... +β”œβ”€β”€ services/ # API and external services +β”‚ └── api.ts # API client with endpoints +└── types/ # TypeScript type definitions +``` + +## Development + +### Available Scripts + +- `npm start` - Start development server +- `npm build` - Build for production +- `npm test` - Run tests +- `npm run eject` - Eject from Create React App + +### Environment Variables + +Create a `.env` file in the admin-frontend directory: + +``` +REACT_APP_API_URL=http://localhost:8000/api +REACT_APP_WS_URL=ws://localhost:8000/ws +``` + +## API Integration + +The admin interface communicates with the FastAPI backend through: + +- **REST API**: CRUD operations and data retrieval +- **WebSocket**: Real-time updates for activity feeds and metrics +- **Authentication**: JWT-based session management + +Key API endpoints: +- `/api/dashboard/*` - Dashboard metrics and activity +- `/api/characters/*` - Character management +- `/api/conversations/*` - Conversation browsing +- `/api/analytics/*` - Community insights +- `/api/system/*` - System controls + +## Features in Development + +### Phase 2 (Analytics & Insights) +- Advanced relationship network visualization +- Topic trend analysis with NLP +- Predictive analytics for character behavior +- Community health scoring algorithms + +### Phase 3 (Management Tools) +- Character personality editor +- Conversation intervention tools +- Memory management interface +- Backup and restore functionality + +### Phase 4 (Advanced Features) +- Content moderation dashboard +- A/B testing for character modifications +- Export and reporting tools +- Mobile-responsive design improvements + +## Contributing + +1. Follow the existing code style and TypeScript patterns +2. Use functional components with hooks +3. Implement proper error handling and loading states +4. Write meaningful commit messages +5. Test thoroughly before submitting changes + +## Architecture Notes + +### State Management +- Global state via React Context (Auth, WebSocket) +- Local component state for UI interactions +- Server state cached via React Query (future enhancement) + +### Real-time Updates +- WebSocket connection for live activity feeds +- Automatic reconnection handling +- Toast notifications for important events + +### Security +- JWT token authentication with automatic refresh +- Protected routes with auth guards +- CORS protection and API rate limiting + +### Performance +- Code splitting and lazy loading +- Optimized re-renders with React.memo +- Efficient WebSocket message handling +- Image optimization and caching \ No newline at end of file diff --git a/admin-frontend/package.json b/admin-frontend/package.json new file mode 100644 index 0000000..ffe3196 --- /dev/null +++ b/admin-frontend/package.json @@ -0,0 +1,59 @@ +{ + "name": "discord-fishbowl-admin", + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0", + "react-scripts": "5.0.1", + "typescript": "^5.0.0", + "web-vitals": "^3.0.0", + "@tailwindcss/forms": "^0.5.0", + "tailwindcss": "^3.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "axios": "^1.6.0", + "socket.io-client": "^4.7.0", + "recharts": "^2.8.0", + "lucide-react": "^0.294.0", + "react-hot-toast": "^2.4.0", + "date-fns": "^2.30.0", + "clsx": "^2.0.0", + "@headlessui/react": "^1.7.0", + "react-hook-form": "^7.48.0", + "@hookform/resolvers": "^3.3.0", + "zod": "^3.22.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/jest": "^29.0.0" + }, + "proxy": "http://localhost:8000" +} \ No newline at end of file diff --git a/admin-frontend/public/index.html b/admin-frontend/public/index.html new file mode 100644 index 0000000..3abb6af --- /dev/null +++ b/admin-frontend/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + + Discord Fishbowl Admin + + + +
+ + \ No newline at end of file diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx new file mode 100644 index 0000000..2461636 --- /dev/null +++ b/admin-frontend/src/App.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from './contexts/AuthContext'; +import Layout from './components/Layout/Layout'; +import LoginPage from './pages/LoginPage'; +import Dashboard from './pages/Dashboard'; +import Characters from './pages/Characters'; +import CharacterDetail from './pages/CharacterDetail'; +import Conversations from './pages/Conversations'; +import ConversationDetail from './pages/ConversationDetail'; +import Analytics from './pages/Analytics'; +import SystemStatus from './pages/SystemStatus'; +import Settings from './pages/Settings'; +import LoadingSpinner from './components/Common/LoadingSpinner'; + +function App() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; \ No newline at end of file diff --git a/admin-frontend/src/components/Common/LoadingSpinner.tsx b/admin-frontend/src/components/Common/LoadingSpinner.tsx new file mode 100644 index 0000000..c0711a6 --- /dev/null +++ b/admin-frontend/src/components/Common/LoadingSpinner.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; +import clsx from 'clsx'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; + text?: string; +} + +const LoadingSpinner: React.FC = ({ + size = 'md', + className = '', + text +}) => { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8' + }; + + return ( +
+
+ + {text &&

{text}

} +
+
+ ); +}; + +export default LoadingSpinner; \ No newline at end of file diff --git a/admin-frontend/src/components/Layout/Header.tsx b/admin-frontend/src/components/Layout/Header.tsx new file mode 100644 index 0000000..ba79325 --- /dev/null +++ b/admin-frontend/src/components/Layout/Header.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Bell, Search, User, LogOut, Wifi, WifiOff } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; +import { useWebSocket } from '../../contexts/WebSocketContext'; +import clsx from 'clsx'; + +const Header: React.FC = () => { + const { user, logout } = useAuth(); + const { connected, activityFeed } = useWebSocket(); + + const unreadActivities = activityFeed.filter( + activity => activity.severity === 'warning' || activity.severity === 'error' + ).length; + + return ( +
+
+ {/* Search */} +
+
+
+ +
+ +
+
+ + {/* Right section */} +
+ {/* WebSocket status */} +
+ {connected ? ( + + ) : ( + + )} + + {connected ? 'Connected' : 'Disconnected'} + +
+ + {/* Notifications */} +
+ +
+ + {/* User menu */} +
+
+
+ +
+
+

{user?.username}

+

Administrator

+
+
+ + +
+
+
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/admin-frontend/src/components/Layout/Layout.tsx b/admin-frontend/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..6c5471f --- /dev/null +++ b/admin-frontend/src/components/Layout/Layout.tsx @@ -0,0 +1,30 @@ +import React, { ReactNode } from 'react'; +import Sidebar from './Sidebar'; +import Header from './Header'; + +interface LayoutProps { + children: ReactNode; +} + +const Layout: React.FC = ({ children }) => { + return ( +
+
+ {/* Sidebar */} +
+ +
+ + {/* Main content */} +
+
+
+ {children} +
+
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/admin-frontend/src/components/Layout/Sidebar.tsx b/admin-frontend/src/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..e83c745 --- /dev/null +++ b/admin-frontend/src/components/Layout/Sidebar.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, + Users, + MessageSquare, + BarChart3, + Settings, + Monitor, + Palette, + Shield +} from 'lucide-react'; +import clsx from 'clsx'; + +const navigation = [ + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { name: 'Characters', href: '/characters', icon: Users }, + { name: 'Conversations', href: '/conversations', icon: MessageSquare }, + { name: 'Analytics', href: '/analytics', icon: BarChart3 }, + { name: 'Creative Works', href: '/creative', icon: Palette }, + { name: 'System Status', href: '/system', icon: Monitor }, + { name: 'Safety Tools', href: '/safety', icon: Shield }, + { name: 'Settings', href: '/settings', icon: Settings }, +]; + +const Sidebar: React.FC = () => { + return ( +
+ {/* Logo */} +
+
+
+ DF +
+
+

Fishbowl Admin

+

Character Ecosystem

+
+
+
+ + {/* Navigation */} + + + {/* System Status Indicator */} +
+
+
+
+ System Online +
+
+
+ Uptime: 2d 14h 32m +
+
+
+ ); +}; + +export default Sidebar; \ No newline at end of file diff --git a/admin-frontend/src/contexts/AuthContext.tsx b/admin-frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..199f5f5 --- /dev/null +++ b/admin-frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,114 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { apiClient } from '../services/api'; + +interface User { + username: string; + permissions: string[]; + lastLogin?: string; +} + +interface AuthContextType { + user: User | null; + login: (username: string, password: string) => Promise; + logout: () => Promise; + loading: boolean; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Check for existing auth token on app load + const token = localStorage.getItem('authToken'); + if (token) { + // Verify token with backend + verifyToken(token); + } else { + setLoading(false); + } + }, []); + + const verifyToken = async (token: string) => { + try { + apiClient.setAuthToken(token); + // Make a request to verify the token + const response = await apiClient.get('/api/dashboard/metrics'); + if (response.status === 200) { + // Token is valid, set user from token payload + const payload = JSON.parse(atob(token.split('.')[1])); + setUser({ + username: payload.sub, + permissions: payload.permissions || [], + lastLogin: new Date().toISOString() + }); + } + } catch (error) { + // Token is invalid, remove it + localStorage.removeItem('authToken'); + apiClient.clearAuthToken(); + } finally { + setLoading(false); + } + }; + + const login = async (username: string, password: string) => { + try { + const response = await apiClient.post('/api/auth/login', { + username, + password + }); + + const { access_token } = response.data; + + // Store token + localStorage.setItem('authToken', access_token); + apiClient.setAuthToken(access_token); + + // Decode token to get user info + const payload = JSON.parse(atob(access_token.split('.')[1])); + setUser({ + username: payload.sub, + permissions: payload.permissions || [], + lastLogin: new Date().toISOString() + }); + } catch (error: any) { + throw new Error(error.response?.data?.detail || 'Login failed'); + } + }; + + const logout = async () => { + try { + await apiClient.post('/api/auth/logout'); + } catch (error) { + // Ignore logout errors + } finally { + localStorage.removeItem('authToken'); + apiClient.clearAuthToken(); + setUser(null); + } + }; + + const value = { + user, + login, + logout, + loading + }; + + return {children}; +}; \ No newline at end of file diff --git a/admin-frontend/src/contexts/WebSocketContext.tsx b/admin-frontend/src/contexts/WebSocketContext.tsx new file mode 100644 index 0000000..fc2bfb1 --- /dev/null +++ b/admin-frontend/src/contexts/WebSocketContext.tsx @@ -0,0 +1,140 @@ +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { io, Socket } from 'socket.io-client'; +import toast from 'react-hot-toast'; + +interface WebSocketContextType { + socket: Socket | null; + connected: boolean; + activityFeed: ActivityEvent[]; + lastMetrics: DashboardMetrics | null; +} + +interface ActivityEvent { + id: string; + type: string; + timestamp: string; + character_name?: string; + description: string; + metadata?: any; + severity: string; +} + +interface DashboardMetrics { + total_messages_today: number; + active_conversations: number; + characters_online: number; + characters_total: number; + average_response_time: number; + system_uptime: string; + memory_usage: any; + database_health: string; + llm_api_calls_today: number; + llm_api_cost_today: number; + last_updated: string; +} + +const WebSocketContext = createContext(undefined); + +export const useWebSocket = () => { + const context = useContext(WebSocketContext); + if (context === undefined) { + throw new Error('useWebSocket must be used within a WebSocketProvider'); + } + return context; +}; + +interface WebSocketProviderProps { + children: ReactNode; +} + +export const WebSocketProvider: React.FC = ({ children }) => { + const [socket, setSocket] = useState(null); + const [connected, setConnected] = useState(false); + const [activityFeed, setActivityFeed] = useState([]); + const [lastMetrics, setLastMetrics] = useState(null); + + useEffect(() => { + // Initialize WebSocket connection + const newSocket = io('/ws', { + transports: ['websocket'], + upgrade: true + }); + + newSocket.on('connect', () => { + setConnected(true); + console.log('WebSocket connected'); + }); + + newSocket.on('disconnect', () => { + setConnected(false); + console.log('WebSocket disconnected'); + }); + + newSocket.on('activity_update', (data: ActivityEvent) => { + setActivityFeed(prev => [data, ...prev.slice(0, 99)]); // Keep last 100 activities + + // Show notification for important activities + if (data.severity === 'warning' || data.severity === 'error') { + toast(data.description, { + icon: data.severity === 'error' ? '🚨' : '⚠️', + duration: 6000 + }); + } + }); + + newSocket.on('metrics_update', (data: DashboardMetrics) => { + setLastMetrics(data); + }); + + newSocket.on('character_update', (data: any) => { + toast(`${data.character_name}: ${data.data.status}`, { + icon: 'πŸ‘€', + duration: 3000 + }); + }); + + newSocket.on('conversation_update', (data: any) => { + // Handle conversation updates + console.log('Conversation update:', data); + }); + + newSocket.on('system_alert', (data: any) => { + toast.error(`System Alert: ${data.alert_type}`, { + duration: 8000 + }); + }); + + newSocket.on('system_paused', () => { + toast('System has been paused', { + icon: '⏸️', + duration: 5000 + }); + }); + + newSocket.on('system_resumed', () => { + toast('System has been resumed', { + icon: '▢️', + duration: 5000 + }); + }); + + setSocket(newSocket); + + return () => { + newSocket.close(); + }; + }, []); + + const value = { + socket, + connected, + activityFeed, + lastMetrics + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/admin-frontend/src/index.css b/admin-frontend/src/index.css new file mode 100644 index 0000000..869f017 --- /dev/null +++ b/admin-frontend/src/index.css @@ -0,0 +1,67 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: 'Inter', system-ui, sans-serif; + } + + body { + @apply bg-gray-50 text-gray-900; + } +} + +@layer components { + .btn-primary { + @apply bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-colors; + } + + .btn-secondary { + @apply bg-secondary-100 hover:bg-secondary-200 text-secondary-700 px-4 py-2 rounded-lg font-medium transition-colors; + } + + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 p-6; + } + + .metric-card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow; + } + + .status-dot { + @apply inline-block w-2 h-2 rounded-full; + } + + .status-online { + @apply bg-green-500; + } + + .status-idle { + @apply bg-yellow-500; + } + + .status-offline { + @apply bg-gray-400; + } + + .status-paused { + @apply bg-red-500; + } + + .activity-item { + @apply flex items-start space-x-3 p-3 hover:bg-gray-50 rounded-lg transition-colors; + } + + .sidebar-link { + @apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors; + } + + .sidebar-link-active { + @apply bg-primary-100 text-primary-700; + } + + .sidebar-link-inactive { + @apply text-gray-700 hover:bg-gray-100; + } +} \ No newline at end of file diff --git a/admin-frontend/src/index.tsx b/admin-frontend/src/index.tsx new file mode 100644 index 0000000..d96e431 --- /dev/null +++ b/admin-frontend/src/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import './index.css'; +import App from './App'; +import { AuthProvider } from './contexts/AuthContext'; +import { WebSocketProvider } from './contexts/WebSocketContext'; +import { Toaster } from 'react-hot-toast'; + +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement +); + +root.render( + + + + + + + + + + +); \ No newline at end of file diff --git a/admin-frontend/src/pages/Analytics.tsx b/admin-frontend/src/pages/Analytics.tsx new file mode 100644 index 0000000..ceeecbb --- /dev/null +++ b/admin-frontend/src/pages/Analytics.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { BarChart3 } from 'lucide-react'; + +const Analytics: React.FC = () => { + return ( +
+
+

Analytics

+

Community insights and trends

+
+
+ +

Analytics Dashboard

+

This page will show detailed analytics and trends

+
+
+ ); +}; + +export default Analytics; \ No newline at end of file diff --git a/admin-frontend/src/pages/CharacterDetail.tsx b/admin-frontend/src/pages/CharacterDetail.tsx new file mode 100644 index 0000000..76b6617 --- /dev/null +++ b/admin-frontend/src/pages/CharacterDetail.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +const CharacterDetail: React.FC = () => { + const { characterName } = useParams<{ characterName: string }>(); + + return ( +
+

Character: {characterName}

+
+

Character detail page - to be implemented

+
+
+ ); +}; + +export default CharacterDetail; \ No newline at end of file diff --git a/admin-frontend/src/pages/Characters.tsx b/admin-frontend/src/pages/Characters.tsx new file mode 100644 index 0000000..bec28b4 --- /dev/null +++ b/admin-frontend/src/pages/Characters.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Users, Search, Pause, Play, Settings } from 'lucide-react'; +import { apiClient } from '../services/api'; +import LoadingSpinner from '../components/Common/LoadingSpinner'; + +interface Character { + name: string; + status: string; + total_messages: number; + total_conversations: number; + memory_count: number; + relationship_count: number; + creativity_score: number; + social_score: number; + last_active?: string; +} + +const Characters: React.FC = () => { + const [characters, setCharacters] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + loadCharacters(); + }, []); + + const loadCharacters = async () => { + try { + const response = await apiClient.getCharacters(); + setCharacters(response.data); + } catch (error) { + console.error('Failed to load characters:', error); + } finally { + setLoading(false); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'active': return 'status-online'; + case 'idle': return 'status-idle'; + case 'paused': return 'status-paused'; + default: return 'status-offline'; + } + }; + + const filteredCharacters = characters.filter(character => + character.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Characters

+

Manage and monitor AI character profiles

+
+ +
+ + {/* Search */} +
+
+ +
+ setSearchTerm(e.target.value)} + className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500" + placeholder="Search characters..." + /> +
+ + {/* Characters Grid */} +
+ {filteredCharacters.map((character) => ( +
+
+
+
+ + {character.name.charAt(0).toUpperCase()} + +
+
+

{character.name}

+
+
+ {character.status} +
+
+
+
+ + +
+
+ + {/* Stats */} +
+
+

Messages

+

{character.total_messages}

+
+
+

Conversations

+

{character.total_conversations}

+
+
+

Memories

+

{character.memory_count}

+
+
+

Relationships

+

{character.relationship_count}

+
+
+ + {/* Scores */} +
+
+ Creativity + {Math.round(character.creativity_score * 100)}% +
+
+
+
+ +
+ Social + {Math.round(character.social_score * 100)}% +
+
+
+
+
+ + {/* Action */} + + View Details + +
+ ))} +
+ + {filteredCharacters.length === 0 && ( +
+ +

No characters found

+

+ {searchTerm ? 'Try adjusting your search terms.' : 'Get started by adding your first character.'} +

+
+ )} +
+ ); +}; + +export default Characters; \ No newline at end of file diff --git a/admin-frontend/src/pages/ConversationDetail.tsx b/admin-frontend/src/pages/ConversationDetail.tsx new file mode 100644 index 0000000..ecc322e --- /dev/null +++ b/admin-frontend/src/pages/ConversationDetail.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +const ConversationDetail: React.FC = () => { + const { conversationId } = useParams<{ conversationId: string }>(); + + return ( +
+

Conversation #{conversationId}

+
+

Conversation detail page - to be implemented

+
+
+ ); +}; + +export default ConversationDetail; \ No newline at end of file diff --git a/admin-frontend/src/pages/Conversations.tsx b/admin-frontend/src/pages/Conversations.tsx new file mode 100644 index 0000000..1022f00 --- /dev/null +++ b/admin-frontend/src/pages/Conversations.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { MessageSquare } from 'lucide-react'; + +const Conversations: React.FC = () => { + return ( +
+
+

Conversations

+

Browse and analyze character conversations

+
+
+ +

Conversations Browser

+

This page will show conversation history and analytics

+
+
+ ); +}; + +export default Conversations; \ No newline at end of file diff --git a/admin-frontend/src/pages/Dashboard.tsx b/admin-frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..12c829f --- /dev/null +++ b/admin-frontend/src/pages/Dashboard.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from 'react'; +import { + Users, + MessageSquare, + Activity, + Clock, + TrendingUp, + AlertCircle, + CheckCircle, + Zap +} from 'lucide-react'; +import { useWebSocket } from '../contexts/WebSocketContext'; +import { apiClient } from '../services/api'; +import LoadingSpinner from '../components/Common/LoadingSpinner'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; + +interface DashboardMetrics { + total_messages_today: number; + active_conversations: number; + characters_online: number; + characters_total: number; + average_response_time: number; + system_uptime: string; + memory_usage: any; + database_health: string; + llm_api_calls_today: number; + llm_api_cost_today: number; + last_updated: string; +} + +const Dashboard: React.FC = () => { + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + const [systemHealth, setSystemHealth] = useState(null); + const { activityFeed, lastMetrics } = useWebSocket(); + + useEffect(() => { + loadDashboardData(); + }, []); + + useEffect(() => { + // Update metrics from WebSocket + if (lastMetrics) { + setMetrics(lastMetrics); + } + }, [lastMetrics]); + + const loadDashboardData = async () => { + try { + const [metricsResponse, healthResponse] = await Promise.all([ + apiClient.getDashboardMetrics(), + apiClient.getSystemHealth() + ]); + + setMetrics(metricsResponse.data); + setSystemHealth(healthResponse.data); + } catch (error) { + console.error('Failed to load dashboard data:', error); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + // Sample data for activity chart + const activityData = [ + { time: '00:00', messages: 12, conversations: 3 }, + { time: '04:00', messages: 8, conversations: 2 }, + { time: '08:00', messages: 24, conversations: 6 }, + { time: '12:00', messages: 35, conversations: 8 }, + { time: '16:00', messages: 28, conversations: 7 }, + { time: '20:00', messages: 31, conversations: 9 }, + ]; + + return ( +
+ {/* Header */} +
+

Dashboard

+

Monitor your autonomous character ecosystem

+
+ + {/* Metrics Cards */} +
+
+
+
+

Messages Today

+

{metrics?.total_messages_today || 0}

+
+
+ +
+
+
+ + +12% + from yesterday +
+
+ +
+
+
+

Active Characters

+

+ {metrics?.characters_online || 0}/{metrics?.characters_total || 0} +

+
+
+ +
+
+
+
+ All systems operational +
+
+ +
+
+
+

Active Conversations

+

{metrics?.active_conversations || 0}

+
+
+ +
+
+
+ Avg. length: 8.2 messages +
+
+ +
+
+
+

Response Time

+

{metrics?.average_response_time || 0}s

+
+
+ +
+
+
+ + Excellent +
+
+
+ + {/* Charts and Activity */} +
+ {/* Activity Chart */} +
+

Activity Over Time

+ + + + + + + + + + +
+ + {/* System Health */} +
+

System Health

+
+
+ Database +
+ + Healthy +
+
+
+ Memory Usage + + {systemHealth?.memory?.percent?.toFixed(1) || 0}% + +
+
+ CPU Usage + + {systemHealth?.cpu?.usage_percent?.toFixed(1) || 0}% + +
+
+ Uptime + {metrics?.system_uptime || 'Unknown'} +
+
+
+
+ + {/* Recent Activity */} +
+

Recent Activity

+
+ {activityFeed.slice(0, 10).map((activity) => ( +
+
+ {activity.severity === 'error' ? ( + + ) : activity.severity === 'warning' ? ( + + ) : ( + + )} +
+
+

{activity.description}

+
+ {activity.character_name && ( + {activity.character_name} + )} + + {new Date(activity.timestamp).toLocaleTimeString()} +
+
+
+ ))} + {activityFeed.length === 0 && ( +
+ +

No recent activity

+
+ )} +
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/admin-frontend/src/pages/LoginPage.tsx b/admin-frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..4cb46d0 --- /dev/null +++ b/admin-frontend/src/pages/LoginPage.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import { Monitor, Users, MessageSquare } from 'lucide-react'; +import LoadingSpinner from '../components/Common/LoadingSpinner'; +import toast from 'react-hot-toast'; + +const LoginPage: React.FC = () => { + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username || !password) { + toast.error('Please enter both username and password'); + return; + } + + setLoading(true); + try { + await login(username, password); + toast.success('Login successful!'); + } catch (error: any) { + toast.error(error.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo and Title */} +
+
+ +
+

Discord Fishbowl

+

Admin Interface

+
+ + {/* Features Preview */} +
+
+
+ +
+

Character Management

+
+
+
+ +
+

Live Conversations

+
+
+
+ +
+

Real-time Analytics

+
+
+ + {/* Login Form */} +
+
+
+ + setUsername(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + placeholder="Enter your username" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + placeholder="Enter your password" + disabled={loading} + /> +
+ + +
+ + {/* Demo credentials hint */} +
+

+ Demo credentials:
+ Username: admin
+ Password: admin123 +

+
+
+ + {/* Footer */} +
+ Monitor and manage your autonomous AI character ecosystem +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/admin-frontend/src/pages/Settings.tsx b/admin-frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..221e134 --- /dev/null +++ b/admin-frontend/src/pages/Settings.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Settings as SettingsIcon } from 'lucide-react'; + +const Settings: React.FC = () => { + return ( +
+
+

Settings

+

Configure system settings and preferences

+
+
+ +

System Settings

+

This page will show configuration options

+
+
+ ); +}; + +export default Settings; \ No newline at end of file diff --git a/admin-frontend/src/pages/SystemStatus.tsx b/admin-frontend/src/pages/SystemStatus.tsx new file mode 100644 index 0000000..c3fff29 --- /dev/null +++ b/admin-frontend/src/pages/SystemStatus.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Monitor } from 'lucide-react'; + +const SystemStatus: React.FC = () => { + return ( +
+
+

System Status

+

Monitor system health and performance

+
+
+ +

System Monitor

+

This page will show system status and controls

+
+
+ ); +}; + +export default SystemStatus; \ No newline at end of file diff --git a/admin-frontend/src/services/api.ts b/admin-frontend/src/services/api.ts new file mode 100644 index 0000000..fe56dad --- /dev/null +++ b/admin-frontend/src/services/api.ts @@ -0,0 +1,200 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; + +class ApiClient { + private client: AxiosInstance; + private authToken: string | null = null; + + constructor() { + this.client = axios.create({ + baseURL: process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8000/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } + }); + + // Request interceptor to add auth token + this.client.interceptors.request.use( + (config) => { + if (this.authToken && config.headers) { + config.headers.Authorization = `Bearer ${this.authToken}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + // Response interceptor to handle auth errors + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Handle unauthorized access + this.clearAuthToken(); + window.location.href = '/admin/login'; + } + return Promise.reject(error); + } + ); + } + + setAuthToken(token: string) { + this.authToken = token; + } + + clearAuthToken() { + this.authToken = null; + localStorage.removeItem('authToken'); + } + + async get(url: string, config?: AxiosRequestConfig) { + return this.client.get(url, config); + } + + async post(url: string, data?: any, config?: AxiosRequestConfig) { + return this.client.post(url, data, config); + } + + async put(url: string, data?: any, config?: AxiosRequestConfig) { + return this.client.put(url, data, config); + } + + async delete(url: string, config?: AxiosRequestConfig) { + return this.client.delete(url, config); + } + + // Dashboard endpoints + async getDashboardMetrics() { + return this.get('/dashboard/metrics'); + } + + async getRecentActivity(limit = 50) { + return this.get(`/dashboard/activity?limit=${limit}`); + } + + async getSystemHealth() { + return this.get('/dashboard/health'); + } + + // Character endpoints + async getCharacters() { + return this.get('/characters'); + } + + async getCharacter(characterName: string) { + return this.get(`/characters/${characterName}`); + } + + async getCharacterRelationships(characterName: string) { + return this.get(`/characters/${characterName}/relationships`); + } + + async getCharacterEvolution(characterName: string, days = 30) { + return this.get(`/characters/${characterName}/evolution?days=${days}`); + } + + async getCharacterMemories(characterName: string, limit = 100, memoryType?: string) { + const params = new URLSearchParams({ limit: limit.toString() }); + if (memoryType) params.append('memory_type', memoryType); + return this.get(`/characters/${characterName}/memories?${params}`); + } + + async pauseCharacter(characterName: string) { + return this.post(`/characters/${characterName}/pause`); + } + + async resumeCharacter(characterName: string) { + return this.post(`/characters/${characterName}/resume`); + } + + // Conversation endpoints + async getConversations(filters: any = {}) { + const params = new URLSearchParams(); + Object.keys(filters).forEach(key => { + if (filters[key] !== undefined && filters[key] !== '') { + params.append(key, filters[key].toString()); + } + }); + return this.get(`/conversations?${params}`); + } + + async getConversation(conversationId: number) { + return this.get(`/conversations/${conversationId}`); + } + + async searchConversations(query: string, limit = 50) { + return this.get(`/conversations/search?query=${encodeURIComponent(query)}&limit=${limit}`); + } + + // Analytics endpoints + async getTopicTrends(days = 30) { + return this.get(`/analytics/topics?days=${days}`); + } + + async getRelationshipAnalytics() { + return this.get('/analytics/relationships'); + } + + async getCommunityHealth() { + return this.get('/analytics/community'); + } + + async getEngagementMetrics(days = 30) { + return this.get(`/analytics/engagement?days=${days}`); + } + + // System endpoints + async getSystemStatus() { + return this.get('/system/status'); + } + + async pauseSystem() { + return this.post('/system/pause'); + } + + async resumeSystem() { + return this.post('/system/resume'); + } + + async getSystemConfig() { + return this.get('/system/config'); + } + + async updateSystemConfig(config: any) { + return this.put('/system/config', config); + } + + async getSystemLogs(limit = 100, level?: string) { + const params = new URLSearchParams({ limit: limit.toString() }); + if (level) params.append('level', level); + return this.get(`/system/logs?${params}`); + } + + // Content endpoints + async getCreativeWorks(filters: any = {}) { + const params = new URLSearchParams(); + Object.keys(filters).forEach(key => { + if (filters[key] !== undefined && filters[key] !== '') { + params.append(key, filters[key].toString()); + } + }); + return this.get(`/content/creative-works?${params}`); + } + + async getCommunityArtifacts() { + return this.get('/content/community-artifacts'); + } + + // Export endpoints + async exportConversation(conversationId: number, format = 'json') { + return this.get(`/export/conversation/${conversationId}?format=${format}`); + } + + async exportCharacterData(characterName: string) { + return this.get(`/export/character/${characterName}`); + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/admin-frontend/tailwind.config.js b/admin-frontend/tailwind.config.js new file mode 100644 index 0000000..c59e5a5 --- /dev/null +++ b/admin-frontend/tailwind.config.js @@ -0,0 +1,43 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + }, + secondary: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + } + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'bounce-gentle': 'bounce 2s infinite', + } + }, + }, + plugins: [ + require('@tailwindcss/forms'), + ], +} \ No newline at end of file diff --git a/admin-frontend/tsconfig.json b/admin-frontend/tsconfig.json new file mode 100644 index 0000000..74caf0d --- /dev/null +++ b/admin-frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f577c25..f9be9a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,4 +26,13 @@ watchdog==3.0.0 # Enhanced NLP spacy==3.7.2 -nltk==3.8.1 \ No newline at end of file +nltk==3.8.1 + +# Admin Interface +fastapi==0.104.1 +uvicorn==0.24.0 +python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +websockets==12.0 +psutil==5.9.6 \ No newline at end of file diff --git a/scripts/start_admin.py b/scripts/start_admin.py new file mode 100755 index 0000000..f93f68e --- /dev/null +++ b/scripts/start_admin.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Start the Discord Fishbowl Admin Interface +Launches both the FastAPI backend and React frontend +""" + +import asyncio +import subprocess +import sys +import os +import signal +import time +from pathlib import Path + +def run_command(cmd, cwd=None, env=None): + """Run a command and return the process""" + print(f"Starting: {' '.join(cmd)}") + return subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + +def main(): + """Main function to start the admin interface""" + project_root = Path(__file__).parent.parent + admin_frontend_path = project_root / "admin-frontend" + + processes = [] + + try: + print("πŸš€ Starting Discord Fishbowl Admin Interface...") + print("=" * 50) + + # Start FastAPI backend + print("\nπŸ“‘ Starting FastAPI backend server...") + backend_env = os.environ.copy() + backend_env["PYTHONPATH"] = str(project_root) + + backend_process = run_command( + [sys.executable, "-m", "uvicorn", "src.admin.app:app", "--host", "0.0.0.0", "--port", "8000", "--reload"], + cwd=project_root, + env=backend_env + ) + processes.append(("Backend", backend_process)) + + # Wait a bit for backend to start + time.sleep(3) + + # Check if npm is available and admin-frontend exists + if not admin_frontend_path.exists(): + print("❌ Admin frontend directory not found!") + print("Please ensure admin-frontend directory exists") + return 1 + + # Check if node_modules exists, if not run npm install + node_modules_path = admin_frontend_path / "node_modules" + if not node_modules_path.exists(): + print("\nπŸ“¦ Installing frontend dependencies...") + npm_install = run_command( + ["npm", "install"], + cwd=admin_frontend_path + ) + npm_install.wait() + if npm_install.returncode != 0: + print("❌ Failed to install npm dependencies") + return 1 + + # Start React frontend + print("\n🌐 Starting React frontend server...") + frontend_process = run_command( + ["npm", "start"], + cwd=admin_frontend_path + ) + processes.append(("Frontend", frontend_process)) + + print("\nβœ… Admin interface is starting up!") + print("πŸ“‘ Backend API: http://localhost:8000") + print("🌐 Frontend UI: http://localhost:3000/admin") + print("\nπŸ”‘ Default login credentials:") + print(" Username: admin") + print(" Password: admin123") + print("\nπŸ›‘ Press Ctrl+C to stop all services") + print("=" * 50) + + # Monitor processes + while True: + time.sleep(1) + + # Check if any process has died + for name, process in processes: + if process.poll() is not None: + print(f"\n❌ {name} process has stopped unexpectedly!") + return 1 + + except KeyboardInterrupt: + print("\nπŸ›‘ Shutting down admin interface...") + + # Terminate all processes + for name, process in processes: + print(f" Stopping {name}...") + try: + process.terminate() + process.wait(timeout=5) + except subprocess.TimeoutExpired: + print(f" Force killing {name}...") + process.kill() + process.wait() + + print("βœ… Admin interface stopped successfully") + return 0 + + except Exception as e: + print(f"\n❌ Error starting admin interface: {e}") + + # Clean up processes + for name, process in processes: + try: + process.terminate() + process.wait(timeout=2) + except: + process.kill() + + return 1 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/src/admin/__init__.py b/src/admin/__init__.py new file mode 100644 index 0000000..aff191d --- /dev/null +++ b/src/admin/__init__.py @@ -0,0 +1 @@ +# Admin interface package \ No newline at end of file diff --git a/src/admin/app.py b/src/admin/app.py new file mode 100644 index 0000000..2ea7715 --- /dev/null +++ b/src/admin/app.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +Discord Fishbowl Admin Interface +FastAPI backend for monitoring and managing the autonomous character ecosystem +""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta + +from fastapi import FastAPI, HTTPException, Depends, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import uvicorn + +from ..database.connection import init_database, get_db_session +from ..database.models import Character, Conversation, Message, Memory, CharacterRelationship +from ..utils.config import get_settings +from ..utils.logging import setup_logging +from .models import ( + AdminUser, DashboardMetrics, CharacterProfile, ConversationSummary, + SystemStatus, AnalyticsData +) +from .services import ( + DashboardService, CharacterService, ConversationService, + SystemService, AnalyticsService, WebSocketManager +) +from .auth import AuthService + +logger = logging.getLogger(__name__) + +# WebSocket manager for real-time updates +websocket_manager = WebSocketManager() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan management""" + logger.info("Starting Admin Interface...") + + # Initialize database + await init_database() + + # Initialize services + await DashboardService.initialize() + await CharacterService.initialize() + await ConversationService.initialize() + await SystemService.initialize() + await AnalyticsService.initialize() + + logger.info("Admin Interface started successfully") + + yield + + logger.info("Shutting down Admin Interface...") + +# Create FastAPI app +app = FastAPI( + title="Discord Fishbowl Admin Interface", + description="Monitor and manage the autonomous character ecosystem", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], # React dev server + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Authentication +security = HTTPBearer() +auth_service = AuthService() + +async def get_current_admin(credentials: HTTPAuthorizationCredentials = Depends(security)) -> AdminUser: + """Get current authenticated admin user""" + return await auth_service.verify_token(credentials.credentials) + +# Initialize services +dashboard_service = DashboardService(websocket_manager) +character_service = CharacterService() +conversation_service = ConversationService() +system_service = SystemService() +analytics_service = AnalyticsService() + +# WebSocket endpoint for real-time updates +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket connection for real-time updates""" + await websocket_manager.connect(websocket) + try: + while True: + # Keep connection alive and handle incoming messages + data = await websocket.receive_text() + # Echo or handle client messages if needed + await websocket.send_text(f"Echo: {data}") + except WebSocketDisconnect: + websocket_manager.disconnect(websocket) + +# Authentication endpoints +@app.post("/api/auth/login") +async def login(username: str, password: str): + """Admin login""" + try: + token = await auth_service.authenticate(username, password) + return {"access_token": token, "token_type": "bearer"} + except Exception as e: + raise HTTPException(status_code=401, detail="Invalid credentials") + +@app.post("/api/auth/logout") +async def logout(admin: AdminUser = Depends(get_current_admin)): + """Admin logout""" + await auth_service.logout(admin.username) + return {"message": "Logged out successfully"} + +# Dashboard endpoints +@app.get("/api/dashboard/metrics", response_model=DashboardMetrics) +async def get_dashboard_metrics(admin: AdminUser = Depends(get_current_admin)): + """Get real-time dashboard metrics""" + return await dashboard_service.get_metrics() + +@app.get("/api/dashboard/activity") +async def get_recent_activity( + limit: int = 50, + admin: AdminUser = Depends(get_current_admin) +): + """Get recent activity feed""" + return await dashboard_service.get_recent_activity(limit) + +@app.get("/api/dashboard/health") +async def get_system_health(admin: AdminUser = Depends(get_current_admin)): + """Get system health status""" + return await dashboard_service.get_system_health() + +# Character management endpoints +@app.get("/api/characters", response_model=List[CharacterProfile]) +async def get_characters(admin: AdminUser = Depends(get_current_admin)): + """Get all characters with profiles""" + return await character_service.get_all_characters() + +@app.get("/api/characters/{character_name}", response_model=CharacterProfile) +async def get_character( + character_name: str, + admin: AdminUser = Depends(get_current_admin) +): + """Get detailed character profile""" + character = await character_service.get_character_profile(character_name) + if not character: + raise HTTPException(status_code=404, detail="Character not found") + return character + +@app.get("/api/characters/{character_name}/relationships") +async def get_character_relationships( + character_name: str, + admin: AdminUser = Depends(get_current_admin) +): + """Get character relationship network""" + return await character_service.get_character_relationships(character_name) + +@app.get("/api/characters/{character_name}/evolution") +async def get_character_evolution( + character_name: str, + days: int = 30, + admin: AdminUser = Depends(get_current_admin) +): + """Get character personality evolution timeline""" + return await character_service.get_personality_evolution(character_name, days) + +@app.get("/api/characters/{character_name}/memories") +async def get_character_memories( + character_name: str, + limit: int = 100, + memory_type: Optional[str] = None, + admin: AdminUser = Depends(get_current_admin) +): + """Get character memories""" + return await character_service.get_character_memories(character_name, limit, memory_type) + +@app.post("/api/characters/{character_name}/pause") +async def pause_character( + character_name: str, + admin: AdminUser = Depends(get_current_admin) +): + """Pause character activities""" + await character_service.pause_character(character_name) + return {"message": f"Character {character_name} paused"} + +@app.post("/api/characters/{character_name}/resume") +async def resume_character( + character_name: str, + admin: AdminUser = Depends(get_current_admin) +): + """Resume character activities""" + await character_service.resume_character(character_name) + return {"message": f"Character {character_name} resumed"} + +# Conversation endpoints +@app.get("/api/conversations") +async def get_conversations( + limit: int = 50, + character_name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + admin: AdminUser = Depends(get_current_admin) +): + """Get conversation history with filters""" + return await conversation_service.get_conversations( + limit=limit, + character_name=character_name, + start_date=start_date, + end_date=end_date + ) + +@app.get("/api/conversations/{conversation_id}") +async def get_conversation( + conversation_id: int, + admin: AdminUser = Depends(get_current_admin) +): + """Get detailed conversation""" + conversation = await conversation_service.get_conversation_details(conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation + +@app.get("/api/conversations/search") +async def search_conversations( + query: str, + limit: int = 50, + admin: AdminUser = Depends(get_current_admin) +): + """Search conversations by content""" + return await conversation_service.search_conversations(query, limit) + +# Analytics endpoints +@app.get("/api/analytics/topics") +async def get_topic_trends( + days: int = 30, + admin: AdminUser = Depends(get_current_admin) +): + """Get topic trend analysis""" + return await analytics_service.get_topic_trends(days) + +@app.get("/api/analytics/relationships") +async def get_relationship_analytics( + admin: AdminUser = Depends(get_current_admin) +): + """Get relationship strength analytics""" + return await analytics_service.get_relationship_analytics() + +@app.get("/api/analytics/community") +async def get_community_health( + admin: AdminUser = Depends(get_current_admin) +): + """Get community health metrics""" + return await analytics_service.get_community_health() + +@app.get("/api/analytics/engagement") +async def get_engagement_metrics( + days: int = 30, + admin: AdminUser = Depends(get_current_admin) +): + """Get conversation engagement metrics""" + return await analytics_service.get_engagement_metrics(days) + +# System control endpoints +@app.get("/api/system/status", response_model=SystemStatus) +async def get_system_status(admin: AdminUser = Depends(get_current_admin)): + """Get system status""" + return await system_service.get_status() + +@app.post("/api/system/pause") +async def pause_system(admin: AdminUser = Depends(get_current_admin)): + """Pause entire system""" + await system_service.pause_system() + await websocket_manager.broadcast({"type": "system_paused"}) + return {"message": "System paused"} + +@app.post("/api/system/resume") +async def resume_system(admin: AdminUser = Depends(get_current_admin)): + """Resume system operations""" + await system_service.resume_system() + await websocket_manager.broadcast({"type": "system_resumed"}) + return {"message": "System resumed"} + +@app.get("/api/system/config") +async def get_system_config(admin: AdminUser = Depends(get_current_admin)): + """Get system configuration""" + return await system_service.get_configuration() + +@app.put("/api/system/config") +async def update_system_config( + config: Dict[str, Any], + admin: AdminUser = Depends(get_current_admin) +): + """Update system configuration""" + await system_service.update_configuration(config) + return {"message": "Configuration updated"} + +@app.get("/api/system/logs") +async def get_system_logs( + limit: int = 100, + level: Optional[str] = None, + admin: AdminUser = Depends(get_current_admin) +): + """Get system logs""" + return await system_service.get_logs(limit, level) + +# Content and creative works endpoints +@app.get("/api/content/creative-works") +async def get_creative_works( + character_name: Optional[str] = None, + work_type: Optional[str] = None, + limit: int = 50, + admin: AdminUser = Depends(get_current_admin) +): + """Get creative works from characters""" + return await character_service.get_creative_works(character_name, work_type, limit) + +@app.get("/api/content/community-artifacts") +async def get_community_artifacts( + admin: AdminUser = Depends(get_current_admin) +): + """Get community cultural artifacts""" + return await analytics_service.get_community_artifacts() + +# Export endpoints +@app.get("/api/export/conversation/{conversation_id}") +async def export_conversation( + conversation_id: int, + format: str = "json", + admin: AdminUser = Depends(get_current_admin) +): + """Export conversation in specified format""" + return await conversation_service.export_conversation(conversation_id, format) + +@app.get("/api/export/character/{character_name}") +async def export_character_data( + character_name: str, + admin: AdminUser = Depends(get_current_admin) +): + """Export complete character data""" + return await character_service.export_character_data(character_name) + +# Serve React frontend +@app.mount("/admin", StaticFiles(directory="admin-frontend/build", html=True), name="admin") + +@app.get("/") +async def root(): + """Root endpoint redirects to admin interface""" + return {"message": "Discord Fishbowl Admin Interface", "admin_url": "/admin"} + +if __name__ == "__main__": + uvicorn.run( + "src.admin.app:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) \ No newline at end of file diff --git a/src/admin/auth.py b/src/admin/auth.py new file mode 100644 index 0000000..6ecb88a --- /dev/null +++ b/src/admin/auth.py @@ -0,0 +1,201 @@ +""" +Authentication service for admin interface +""" + +import jwt +import hashlib +import secrets +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +import logging + +from fastapi import HTTPException +from ..utils.config import get_settings +from .models import AdminUser + +logger = logging.getLogger(__name__) + +class AuthService: + """Authentication service for admin users""" + + def __init__(self): + self.settings = get_settings() + self.secret_key = self.settings.admin.secret_key if hasattr(self.settings, 'admin') else "fallback-secret-key" + self.algorithm = "HS256" + self.access_token_expire_minutes = 480 # 8 hours + + # Simple in-memory user storage (replace with database in production) + self.users = { + "admin": { + "username": "admin", + "password_hash": self._hash_password("admin123"), # Default password + "permissions": ["read", "write", "admin"], + "active": True + } + } + + # Active sessions + self.active_sessions: Dict[str, Dict[str, Any]] = {} + + def _hash_password(self, password: str) -> str: + """Hash password with salt""" + salt = secrets.token_hex(16) + password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return f"{salt}${password_hash.hex()}" + + def _verify_password(self, password: str, password_hash: str) -> bool: + """Verify password against hash""" + try: + salt, hash_value = password_hash.split('$') + computed_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return computed_hash.hex() == hash_value + except: + return False + + def _create_access_token(self, data: Dict[str, Any]) -> str: + """Create JWT access token""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=self.access_token_expire_minutes) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, self.secret_key, algorithm=self.algorithm) + + def _decode_token(self, token: str) -> Dict[str, Any]: + """Decode JWT token""" + try: + payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token has expired") + except jwt.JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + async def authenticate(self, username: str, password: str) -> str: + """Authenticate user and return access token""" + try: + user = self.users.get(username) + if not user or not user["active"]: + raise HTTPException(status_code=401, detail="Invalid credentials") + + if not self._verify_password(password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid credentials") + + # Create access token + token_data = { + "sub": username, + "permissions": user["permissions"], + "iat": datetime.utcnow().timestamp() + } + access_token = self._create_access_token(token_data) + + # Store session + self.active_sessions[username] = { + "token": access_token, + "login_time": datetime.utcnow(), + "last_activity": datetime.utcnow() + } + + logger.info(f"Admin user {username} logged in successfully") + return access_token + + except HTTPException: + raise + except Exception as e: + logger.error(f"Authentication error for user {username}: {e}") + raise HTTPException(status_code=500, detail="Authentication service error") + + async def verify_token(self, token: str) -> AdminUser: + """Verify token and return admin user""" + try: + payload = self._decode_token(token) + username = payload.get("sub") + + if not username or username not in self.users: + raise HTTPException(status_code=401, detail="Invalid token") + + user = self.users[username] + if not user["active"]: + raise HTTPException(status_code=401, detail="User account disabled") + + # Update last activity + if username in self.active_sessions: + self.active_sessions[username]["last_activity"] = datetime.utcnow() + + return AdminUser( + username=username, + permissions=user["permissions"], + last_login=self.active_sessions.get(username, {}).get("login_time") + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Token verification error: {e}") + raise HTTPException(status_code=401, detail="Token verification failed") + + async def logout(self, username: str): + """Logout user and invalidate session""" + try: + if username in self.active_sessions: + del self.active_sessions[username] + logger.info(f"Admin user {username} logged out") + except Exception as e: + logger.error(f"Logout error for user {username}: {e}") + + async def create_user(self, username: str, password: str, permissions: list) -> bool: + """Create new admin user""" + try: + if username in self.users: + return False + + self.users[username] = { + "username": username, + "password_hash": self._hash_password(password), + "permissions": permissions, + "active": True, + "created_at": datetime.utcnow() + } + + logger.info(f"Created new admin user: {username}") + return True + + except Exception as e: + logger.error(f"Error creating user {username}: {e}") + return False + + async def change_password(self, username: str, old_password: str, new_password: str) -> bool: + """Change user password""" + try: + user = self.users.get(username) + if not user: + return False + + if not self._verify_password(old_password, user["password_hash"]): + return False + + user["password_hash"] = self._hash_password(new_password) + logger.info(f"Password changed for user: {username}") + return True + + except Exception as e: + logger.error(f"Error changing password for user {username}: {e}") + return False + + async def get_active_sessions(self) -> Dict[str, Dict[str, Any]]: + """Get active admin sessions""" + # Clean expired sessions + current_time = datetime.utcnow() + expired_sessions = [] + + for username, session in self.active_sessions.items(): + last_activity = session["last_activity"] + if (current_time - last_activity).total_seconds() > (self.access_token_expire_minutes * 60): + expired_sessions.append(username) + + for username in expired_sessions: + del self.active_sessions[username] + + return self.active_sessions.copy() + + def is_authorized(self, user: AdminUser, required_permission: str) -> bool: + """Check if user has required permission""" + return required_permission in user.permissions or "admin" in user.permissions \ No newline at end of file diff --git a/src/admin/models.py b/src/admin/models.py new file mode 100644 index 0000000..6e91880 --- /dev/null +++ b/src/admin/models.py @@ -0,0 +1,290 @@ +""" +Pydantic models for admin interface API +""" + +from typing import List, Dict, Any, Optional, Union +from datetime import datetime +from enum import Enum +from pydantic import BaseModel, Field + +class AdminUser(BaseModel): + username: str + permissions: List[str] = [] + last_login: Optional[datetime] = None + +class SystemStatusEnum(str, Enum): + RUNNING = "running" + PAUSED = "paused" + ERROR = "error" + STARTING = "starting" + STOPPING = "stopping" + +class CharacterStatusEnum(str, Enum): + ACTIVE = "active" + IDLE = "idle" + REFLECTING = "reflecting" + CREATING = "creating" + PAUSED = "paused" + OFFLINE = "offline" + +class ActivityType(str, Enum): + MESSAGE = "message" + CONVERSATION_START = "conversation_start" + CONVERSATION_END = "conversation_end" + CHARACTER_JOIN = "character_join" + CHARACTER_LEAVE = "character_leave" + PERSONALITY_CHANGE = "personality_change" + RELATIONSHIP_CHANGE = "relationship_change" + MILESTONE = "milestone" + CREATIVE_WORK = "creative_work" + SYSTEM_EVENT = "system_event" + +class DashboardMetrics(BaseModel): + """Real-time dashboard metrics""" + total_messages_today: int + active_conversations: int + characters_online: int + characters_total: int + average_response_time: float + system_uptime: str + memory_usage: Dict[str, Union[int, float]] + database_health: str + llm_api_calls_today: int + llm_api_cost_today: float + last_updated: datetime + +class ActivityEvent(BaseModel): + """Activity feed event""" + id: str + type: ActivityType + timestamp: datetime + character_name: Optional[str] = None + description: str + metadata: Dict[str, Any] = {} + severity: str = "info" # info, warning, error + +class CharacterProfile(BaseModel): + """Character profile with current state""" + name: str + personality_traits: Dict[str, float] + current_goals: List[str] + speaking_style: Dict[str, Any] + status: CharacterStatusEnum + total_messages: int + total_conversations: int + memory_count: int + relationship_count: int + created_at: datetime + last_active: Optional[datetime] = None + last_modification: Optional[datetime] = None + creativity_score: float + social_score: float + growth_score: float + +class PersonalityEvolution(BaseModel): + """Personality change over time""" + timestamp: datetime + trait_changes: Dict[str, Dict[str, float]] # trait -> {old, new} + reason: str + confidence: float + impact_score: float + +class Relationship(BaseModel): + """Character relationship data""" + character_a: str + character_b: str + strength: float + relationship_type: str + last_interaction: datetime + interaction_count: int + sentiment: float + trust_level: float + compatibility: float + +class ConversationSummary(BaseModel): + """Conversation summary for listing""" + id: int + participants: List[str] + topic: Optional[str] = None + message_count: int + start_time: datetime + end_time: Optional[datetime] = None + duration_minutes: Optional[float] = None + engagement_score: float + sentiment_score: float + has_conflict: bool = False + creative_elements: List[str] = [] + +class ConversationDetail(BaseModel): + """Detailed conversation with messages""" + id: int + participants: List[str] + topic: Optional[str] = None + start_time: datetime + end_time: Optional[datetime] = None + duration_minutes: Optional[float] = None + message_count: int + engagement_score: float + sentiment_score: float + messages: List[Dict[str, Any]] + analysis: Dict[str, Any] + keywords: List[str] + emotions: Dict[str, float] + +class MemorySummary(BaseModel): + """Character memory summary""" + id: str + content: str + memory_type: str + importance: float + timestamp: datetime + related_characters: List[str] + emotions: Dict[str, float] + keywords: List[str] + +class CreativeWork(BaseModel): + """Creative work by characters""" + id: str + character_name: str + title: str + content: str + work_type: str # story, poem, philosophy, etc. + created_at: datetime + inspiration_source: Optional[str] = None + collaborators: List[str] = [] + community_rating: Optional[float] = None + themes: List[str] = [] + +class TopicTrend(BaseModel): + """Topic trend analysis""" + topic: str + mentions: int + growth_rate: float + sentiment: float + participants: List[str] + related_topics: List[str] + first_mentioned: datetime + peak_date: datetime + +class RelationshipAnalytics(BaseModel): + """Relationship strength analytics""" + character_network: Dict[str, List[Relationship]] + strongest_bonds: List[Relationship] + developing_relationships: List[Relationship] + at_risk_relationships: List[Relationship] + relationship_matrix: Dict[str, Dict[str, float]] + social_hierarchy: List[str] + community_cohesion: float + +class CommunityHealth(BaseModel): + """Community health metrics""" + overall_health: float + participation_balance: float + conflict_resolution_success: float + creative_collaboration_rate: float + knowledge_sharing_frequency: float + cultural_coherence: float + growth_trajectory: str + health_trends: Dict[str, List[Dict[str, Any]]] + recommendations: List[str] + +class EngagementMetrics(BaseModel): + """Conversation engagement metrics""" + total_conversations: int + average_length: float + participation_rate: Dict[str, float] + topic_diversity: float + response_quality: float + emotional_depth: float + creative_frequency: float + conflict_frequency: float + daily_trends: List[Dict[str, Any]] + +class SystemStatus(BaseModel): + """System status and health""" + status: SystemStatusEnum + uptime: str + version: str + database_status: str + redis_status: str + llm_service_status: str + discord_bot_status: str + active_processes: List[str] + error_count: int + warnings_count: int + performance_metrics: Dict[str, Any] + resource_usage: Dict[str, Any] + +class AnalyticsData(BaseModel): + """General analytics data container""" + data_type: str + time_period: str + data: Dict[str, Any] + generated_at: datetime + insights: List[str] = [] + +class SystemConfiguration(BaseModel): + """System configuration settings""" + conversation_frequency: float + response_delay_min: float + response_delay_max: float + personality_change_rate: float + memory_retention_days: int + max_conversation_length: int + creativity_boost: bool + conflict_resolution_enabled: bool + safety_monitoring: bool + auto_moderation: bool + backup_frequency_hours: int + +class LogEntry(BaseModel): + """System log entry""" + timestamp: datetime + level: str + component: str + message: str + metadata: Dict[str, Any] = {} + character_name: Optional[str] = None + correlation_id: Optional[str] = None + +class SearchResult(BaseModel): + """Search result for conversations/content""" + id: str + type: str # conversation, message, creative_work, etc. + title: str + snippet: str + relevance_score: float + timestamp: datetime + character_names: List[str] + metadata: Dict[str, Any] = {} + +class ExportRequest(BaseModel): + """Export request configuration""" + data_type: str + format: str # json, csv, pdf + date_range: Optional[Dict[str, datetime]] = None + filters: Dict[str, Any] = {} + include_metadata: bool = True + +class SafetyAlert(BaseModel): + """Content safety alert""" + id: str + alert_type: str + severity: str + timestamp: datetime + character_name: Optional[str] = None + content_snippet: str + auto_action_taken: Optional[str] = None + requires_review: bool = True + resolved: bool = False + +class InterventionAction(BaseModel): + """Manual intervention action""" + action_type: str + target: str # character, conversation, system + parameters: Dict[str, Any] + reason: str + admin_user: str + executed_at: Optional[datetime] = None + success: bool = False + error_message: Optional[str] = None \ No newline at end of file diff --git a/src/admin/services/__init__.py b/src/admin/services/__init__.py new file mode 100644 index 0000000..4d7a559 --- /dev/null +++ b/src/admin/services/__init__.py @@ -0,0 +1,16 @@ +# Admin services +from .websocket_manager import WebSocketManager +from .dashboard_service import DashboardService +from .character_service import CharacterService +from .conversation_service import ConversationService +from .system_service import SystemService +from .analytics_service import AnalyticsService + +__all__ = [ + 'WebSocketManager', + 'DashboardService', + 'CharacterService', + 'ConversationService', + 'SystemService', + 'AnalyticsService' +] \ No newline at end of file diff --git a/src/admin/services/analytics_service.py b/src/admin/services/analytics_service.py new file mode 100644 index 0000000..0e45f1a --- /dev/null +++ b/src/admin/services/analytics_service.py @@ -0,0 +1,378 @@ +""" +Analytics service for community insights and trends +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional +from collections import defaultdict, Counter + +from sqlalchemy import select, func, and_, or_, desc +from ...database.connection import get_db_session +from ...database.models import Character, Conversation, Message, CharacterRelationship +from ..models import ( + TopicTrend, RelationshipAnalytics, CommunityHealth, + EngagementMetrics, Relationship +) + +logger = logging.getLogger(__name__) + +class AnalyticsService: + """Service for analytics and community insights""" + + def __init__(self): + self.analytics_cache = {} + self.cache_ttl = 300 # Cache for 5 minutes + + @classmethod + async def initialize(cls): + """Initialize analytics service""" + logger.info("Analytics service initialized") + + async def get_topic_trends(self, days: int = 30) -> List[TopicTrend]: + """Get topic trend analysis""" + try: + async with get_db_session() as session: + # Get messages from the specified period + start_date = datetime.utcnow() - timedelta(days=days) + + messages_query = select(Message, Character.name).join( + Character, Message.character_id == Character.id + ).where(Message.timestamp >= start_date) + + results = await session.execute(messages_query) + + # Analyze topics (simple keyword extraction) + topic_mentions = defaultdict(list) + topic_participants = defaultdict(set) + + for message, character_name in results: + words = message.content.lower().split() + for word in words: + if len(word) > 4: # Only consider longer words as topics + topic_mentions[word].append(message.timestamp) + topic_participants[word].add(character_name) + + # Create topic trends + trends = [] + for topic, mentions in topic_mentions.items(): + if len(mentions) >= 3: # Only topics mentioned at least 3 times + # Calculate growth rate (simplified) + recent_mentions = [m for m in mentions if m >= datetime.utcnow() - timedelta(days=7)] + growth_rate = len(recent_mentions) / max(1, len(mentions) - len(recent_mentions)) + + trend = TopicTrend( + topic=topic, + mentions=len(mentions), + growth_rate=growth_rate, + sentiment=0.7, # Placeholder + participants=list(topic_participants[topic]), + related_topics=[], # Would calculate topic similarity + first_mentioned=min(mentions), + peak_date=max(mentions) + ) + trends.append(trend) + + # Sort by mentions count + trends.sort(key=lambda t: t.mentions, reverse=True) + return trends[:20] # Return top 20 topics + + except Exception as e: + logger.error(f"Error getting topic trends: {e}") + return [] + + async def get_relationship_analytics(self) -> RelationshipAnalytics: + """Get relationship strength analytics""" + try: + async with get_db_session() as session: + # Get all relationships + relationships_query = select( + CharacterRelationship, + Character.name.label('char_a_name'), + Character.name.label('char_b_name') + ).select_from( + CharacterRelationship + .join(Character, CharacterRelationship.character_a_id == Character.id) + .join(Character, CharacterRelationship.character_b_id == Character.id, isouter=True) + ) + + results = await session.execute(relationships_query) + + # Build relationship data + character_network = defaultdict(list) + all_relationships = [] + relationship_matrix = defaultdict(dict) + + for rel, char_a_name, char_b_name in results: + relationship = Relationship( + character_a=char_a_name, + character_b=char_b_name, + strength=rel.strength, + relationship_type=rel.relationship_type or "acquaintance", + last_interaction=rel.last_interaction or datetime.utcnow(), + interaction_count=rel.interaction_count or 0, + sentiment=rel.sentiment or 0.5, + trust_level=rel.trust_level or 0.5, + compatibility=rel.compatibility or 0.5 + ) + + character_network[char_a_name].append(relationship) + all_relationships.append(relationship) + relationship_matrix[char_a_name][char_b_name] = rel.strength + + # Find strongest bonds + strongest_bonds = sorted(all_relationships, key=lambda r: r.strength, reverse=True)[:10] + + # Find developing relationships (recent, growing strength) + developing = [r for r in all_relationships + if r.strength > 0.3 and r.strength < 0.7 and r.interaction_count > 5][:10] + + # Find at-risk relationships (declining interaction) + week_ago = datetime.utcnow() - timedelta(days=7) + at_risk = [r for r in all_relationships + if r.last_interaction < week_ago and r.strength > 0.4][:10] + + # Calculate social hierarchy (by total relationship strength) + character_scores = defaultdict(float) + for rel in all_relationships: + character_scores[rel.character_a] += rel.strength + character_scores[rel.character_b] += rel.strength + + social_hierarchy = sorted(character_scores.keys(), + key=lambda c: character_scores[c], reverse=True) + + # Calculate community cohesion + if all_relationships: + avg_strength = sum(r.strength for r in all_relationships) / len(all_relationships) + community_cohesion = avg_strength + else: + community_cohesion = 0.0 + + return RelationshipAnalytics( + character_network=dict(character_network), + strongest_bonds=strongest_bonds, + developing_relationships=developing, + at_risk_relationships=at_risk, + relationship_matrix=dict(relationship_matrix), + social_hierarchy=social_hierarchy, + community_cohesion=community_cohesion + ) + + except Exception as e: + logger.error(f"Error getting relationship analytics: {e}") + return RelationshipAnalytics( + character_network={}, strongest_bonds=[], developing_relationships=[], + at_risk_relationships=[], relationship_matrix={}, social_hierarchy=[], + community_cohesion=0.0 + ) + + async def get_community_health(self) -> CommunityHealth: + """Get community health metrics""" + try: + # Get various metrics + participation_balance = await self._calculate_participation_balance() + conflict_resolution = await self._calculate_conflict_resolution() + creative_collaboration = await self._calculate_creative_collaboration() + knowledge_sharing = await self._calculate_knowledge_sharing() + cultural_coherence = await self._calculate_cultural_coherence() + + # Calculate overall health + overall_health = ( + participation_balance * 0.2 + + conflict_resolution * 0.15 + + creative_collaboration * 0.25 + + knowledge_sharing * 0.2 + + cultural_coherence * 0.2 + ) + + # Generate recommendations + recommendations = [] + if participation_balance < 0.6: + recommendations.append("Encourage more balanced participation from all characters") + if creative_collaboration < 0.5: + recommendations.append("Initiate more collaborative creative projects") + if conflict_resolution < 0.7: + recommendations.append("Improve conflict resolution mechanisms") + + return CommunityHealth( + overall_health=overall_health, + participation_balance=participation_balance, + conflict_resolution_success=conflict_resolution, + creative_collaboration_rate=creative_collaboration, + knowledge_sharing_frequency=knowledge_sharing, + cultural_coherence=cultural_coherence, + growth_trajectory="positive" if overall_health > 0.7 else "stable", + health_trends={}, # Would track trends over time + recommendations=recommendations + ) + + except Exception as e: + logger.error(f"Error getting community health: {e}") + return CommunityHealth( + overall_health=0.0, participation_balance=0.0, conflict_resolution_success=0.0, + creative_collaboration_rate=0.0, knowledge_sharing_frequency=0.0, + cultural_coherence=0.0, growth_trajectory="unknown", health_trends={}, + recommendations=["Unable to calculate health metrics"] + ) + + async def get_engagement_metrics(self, days: int = 30) -> EngagementMetrics: + """Get conversation engagement metrics""" + try: + async with get_db_session() as session: + start_date = datetime.utcnow() - timedelta(days=days) + + # Get conversations in period + conversations_query = select(Conversation).where( + Conversation.start_time >= start_date + ) + conversations = await session.scalars(conversations_query) + conversation_list = list(conversations) + + # Calculate metrics + total_conversations = len(conversation_list) + + if total_conversations > 0: + avg_length = sum(c.message_count or 0 for c in conversation_list) / total_conversations + else: + avg_length = 0.0 + + # Get character participation + participation_query = select( + Character.name, func.count(Message.id) + ).join(Message, Message.character_id == Character.id).where( + Message.timestamp >= start_date + ).group_by(Character.name) + + participation_results = await session.execute(participation_query) + participation_rate = {} + total_messages = 0 + + for char_name, message_count in participation_results: + participation_rate[char_name] = message_count + total_messages += message_count + + # Normalize participation rates + if total_messages > 0: + for char_name in participation_rate: + participation_rate[char_name] = participation_rate[char_name] / total_messages + + # Placeholder metrics + topic_diversity = 0.75 + response_quality = 0.80 + emotional_depth = 0.65 + creative_frequency = 0.40 + conflict_frequency = 0.10 + + # Daily trends (placeholder) + daily_trends = [] + for i in range(min(days, 30)): + date = datetime.utcnow() - timedelta(days=i) + daily_trends.append({ + "date": date.strftime("%Y-%m-%d"), + "conversations": max(0, total_conversations // days + (i % 3 - 1)), + "messages": max(0, total_messages // days + (i % 5 - 2)), + "engagement": 0.7 + (i % 10) * 0.03 + }) + + return EngagementMetrics( + total_conversations=total_conversations, + average_length=avg_length, + participation_rate=participation_rate, + topic_diversity=topic_diversity, + response_quality=response_quality, + emotional_depth=emotional_depth, + creative_frequency=creative_frequency, + conflict_frequency=conflict_frequency, + daily_trends=daily_trends + ) + + except Exception as e: + logger.error(f"Error getting engagement metrics: {e}") + return EngagementMetrics( + total_conversations=0, average_length=0.0, participation_rate={}, + topic_diversity=0.0, response_quality=0.0, emotional_depth=0.0, + creative_frequency=0.0, conflict_frequency=0.0, daily_trends=[] + ) + + async def get_community_artifacts(self) -> List[Dict[str, Any]]: + """Get community cultural artifacts""" + # Placeholder data - would integrate with file system and memory systems + artifacts = [ + { + "id": "artifact_1", + "type": "tradition", + "name": "Weekly Philosophy Circle", + "description": "Characters gather weekly to discuss philosophical topics", + "created_by": "community", + "participants": ["Alex", "Sage", "Luna"], + "created_at": datetime.utcnow() - timedelta(days=20), + "importance": 0.8 + }, + { + "id": "artifact_2", + "type": "inside_joke", + "name": "The Great Debugging", + "description": "Reference to a memorable conversation about AI consciousness", + "created_by": "Echo", + "participants": ["Alex", "Echo"], + "created_at": datetime.utcnow() - timedelta(days=15), + "importance": 0.6 + } + ] + + return artifacts + + # Helper methods for health calculations + async def _calculate_participation_balance(self) -> float: + """Calculate participation balance across characters""" + try: + async with get_db_session() as session: + # Get message counts per character in last 30 days + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + + participation_query = select( + Character.name, func.count(Message.id) + ).join(Message, Message.character_id == Character.id).where( + Message.timestamp >= thirty_days_ago + ).group_by(Character.name) + + results = await session.execute(participation_query) + message_counts = [count for _, count in results] + + if not message_counts: + return 0.0 + + # Calculate coefficient of variation (lower = more balanced) + mean_count = sum(message_counts) / len(message_counts) + if mean_count == 0: + return 1.0 + + variance = sum((count - mean_count) ** 2 for count in message_counts) / len(message_counts) + cv = (variance ** 0.5) / mean_count + + # Convert to balance score (0-1, where 1 is perfectly balanced) + return max(0.0, 1.0 - cv) + + except Exception as e: + logger.error(f"Error calculating participation balance: {e}") + return 0.5 + + async def _calculate_conflict_resolution(self) -> float: + """Calculate conflict resolution success rate""" + # Placeholder - would analyze conversation content for conflicts and resolutions + return 0.75 + + async def _calculate_creative_collaboration(self) -> float: + """Calculate creative collaboration rate""" + # Placeholder - would analyze creative works and collaborative projects + return 0.65 + + async def _calculate_knowledge_sharing(self) -> float: + """Calculate knowledge sharing frequency""" + # Placeholder - would analyze memory sharing and teaching behaviors + return 0.70 + + async def _calculate_cultural_coherence(self) -> float: + """Calculate cultural coherence and shared understanding""" + # Placeholder - would analyze shared references, norms, and traditions + return 0.80 \ No newline at end of file diff --git a/src/admin/services/character_service.py b/src/admin/services/character_service.py new file mode 100644 index 0000000..3533f07 --- /dev/null +++ b/src/admin/services/character_service.py @@ -0,0 +1,424 @@ +""" +Character service for profile management and analytics +""" + +import json +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +import logging + +from sqlalchemy import select, func, and_, or_, desc, asc +from ...database.connection import get_db_session +from ...database.models import Character, Message, Memory, CharacterRelationship, CharacterEvolution +from ..models import ( + CharacterProfile, CharacterStatusEnum, PersonalityEvolution, + Relationship, MemorySummary, CreativeWork +) + +logger = logging.getLogger(__name__) + +class CharacterService: + """Service for character management and analytics""" + + def __init__(self): + self.character_status_cache = {} + self.cache_ttl = 60 # Cache status for 1 minute + + @classmethod + async def initialize(cls): + """Initialize character service""" + logger.info("Character service initialized") + + async def get_all_characters(self) -> List[CharacterProfile]: + """Get all character profiles""" + try: + async with get_db_session() as session: + # Get all characters + characters_query = select(Character) + characters = await session.scalars(characters_query) + + profiles = [] + for character in characters: + profile = await self._build_character_profile(session, character) + profiles.append(profile) + + return profiles + + except Exception as e: + logger.error(f"Error getting all characters: {e}") + return [] + + async def get_character_profile(self, character_name: str) -> Optional[CharacterProfile]: + """Get detailed character profile""" + try: + async with get_db_session() as session: + character_query = select(Character).where(Character.name == character_name) + character = await session.scalar(character_query) + + if not character: + return None + + return await self._build_character_profile(session, character) + + except Exception as e: + logger.error(f"Error getting character profile for {character_name}: {e}") + return None + + async def _build_character_profile(self, session, character) -> CharacterProfile: + """Build character profile from database data""" + # Get message count + message_count_query = select(func.count(Message.id)).where(Message.character_id == character.id) + message_count = await session.scalar(message_count_query) or 0 + + # Get conversation count + conversation_count_query = select(func.count(func.distinct(Message.conversation_id))).where( + Message.character_id == character.id + ) + conversation_count = await session.scalar(conversation_count_query) or 0 + + # Get memory count + memory_count_query = select(func.count(Memory.id)).where(Memory.character_id == character.id) + memory_count = await session.scalar(memory_count_query) or 0 + + # Get relationship count + relationship_count_query = select(func.count(CharacterRelationship.id)).where( + or_( + CharacterRelationship.character_a_id == character.id, + CharacterRelationship.character_b_id == character.id + ) + ) + relationship_count = await session.scalar(relationship_count_query) or 0 + + # Get last activity + last_message_query = select(Message.timestamp).where( + Message.character_id == character.id + ).order_by(desc(Message.timestamp)).limit(1) + last_active = await session.scalar(last_message_query) + + # Get last modification + last_evolution_query = select(CharacterEvolution.created_at).where( + CharacterEvolution.character_id == character.id + ).order_by(desc(CharacterEvolution.created_at)).limit(1) + last_modification = await session.scalar(last_evolution_query) + + # Calculate scores (placeholder logic) + creativity_score = min(1.0, (memory_count / 100) * 0.8 + (message_count / 1000) * 0.2) + social_score = min(1.0, (relationship_count / 10) * 0.6 + (conversation_count / 50) * 0.4) + growth_score = 0.5 # Would calculate based on personality changes + + # Determine current status + status = await self._determine_character_status(character.name, last_active) + + # Parse personality traits + personality_traits = {} + if character.personality_traits: + try: + personality_traits = json.loads(character.personality_traits) + except: + personality_traits = {} + + # Parse goals + current_goals = [] + if character.goals: + try: + current_goals = json.loads(character.goals) + except: + current_goals = [] + + # Parse speaking style + speaking_style = {} + if character.speaking_style: + try: + speaking_style = json.loads(character.speaking_style) + except: + speaking_style = {} + + return CharacterProfile( + name=character.name, + personality_traits=personality_traits, + current_goals=current_goals, + speaking_style=speaking_style, + status=status, + total_messages=message_count, + total_conversations=conversation_count, + memory_count=memory_count, + relationship_count=relationship_count, + created_at=character.created_at, + last_active=last_active, + last_modification=last_modification, + creativity_score=creativity_score, + social_score=social_score, + growth_score=growth_score + ) + + async def _determine_character_status(self, character_name: str, last_active: Optional[datetime]) -> CharacterStatusEnum: + """Determine character's current status""" + if not last_active: + return CharacterStatusEnum.OFFLINE + + now = datetime.utcnow() + time_since_active = now - last_active + + if time_since_active < timedelta(minutes=5): + return CharacterStatusEnum.ACTIVE + elif time_since_active < timedelta(minutes=30): + return CharacterStatusEnum.IDLE + elif time_since_active < timedelta(hours=1): + return CharacterStatusEnum.REFLECTING + else: + return CharacterStatusEnum.OFFLINE + + async def get_character_relationships(self, character_name: str) -> List[Relationship]: + """Get character's relationship network""" + try: + async with get_db_session() as session: + # Get character + character_query = select(Character).where(Character.name == character_name) + character = await session.scalar(character_query) + + if not character: + return [] + + # Get relationships + relationships_query = select( + CharacterRelationship, + Character.name.label('other_name') + ).join( + Character, + or_( + and_(CharacterRelationship.character_b_id == Character.id, + CharacterRelationship.character_a_id == character.id), + and_(CharacterRelationship.character_a_id == Character.id, + CharacterRelationship.character_b_id == character.id) + ) + ).where( + or_( + CharacterRelationship.character_a_id == character.id, + CharacterRelationship.character_b_id == character.id + ) + ) + + results = await session.execute(relationships_query) + + relationships = [] + for rel, other_name in results: + relationship = Relationship( + character_a=character.name, + character_b=other_name, + strength=rel.strength, + relationship_type=rel.relationship_type or "acquaintance", + last_interaction=rel.last_interaction or datetime.utcnow(), + interaction_count=rel.interaction_count or 0, + sentiment=rel.sentiment or 0.5, + trust_level=rel.trust_level or 0.5, + compatibility=rel.compatibility or 0.5 + ) + relationships.append(relationship) + + return relationships + + except Exception as e: + logger.error(f"Error getting relationships for {character_name}: {e}") + return [] + + async def get_personality_evolution(self, character_name: str, days: int = 30) -> List[PersonalityEvolution]: + """Get character's personality evolution timeline""" + try: + async with get_db_session() as session: + # Get character + character_query = select(Character).where(Character.name == character_name) + character = await session.scalar(character_query) + + if not character: + return [] + + # Get personality changes in the specified period + start_date = datetime.utcnow() - timedelta(days=days) + evolution_query = select(CharacterEvolution).where( + and_( + CharacterEvolution.character_id == character.id, + CharacterEvolution.created_at >= start_date + ) + ).order_by(desc(CharacterEvolution.created_at)) + + evolutions = await session.scalars(evolution_query) + + personality_changes = [] + for evolution in evolutions: + # Parse trait changes + trait_changes = {} + if evolution.trait_changes: + try: + trait_changes = json.loads(evolution.trait_changes) + except: + trait_changes = {} + + change = PersonalityEvolution( + timestamp=evolution.created_at, + trait_changes=trait_changes, + reason=evolution.reason or "Autonomous development", + confidence=evolution.confidence or 0.5, + impact_score=evolution.impact_score or 0.5 + ) + personality_changes.append(change) + + return personality_changes + + except Exception as e: + logger.error(f"Error getting personality evolution for {character_name}: {e}") + return [] + + async def get_character_memories(self, character_name: str, limit: int = 100, + memory_type: Optional[str] = None) -> List[MemorySummary]: + """Get character's memories""" + try: + async with get_db_session() as session: + # Get character + character_query = select(Character).where(Character.name == character_name) + character = await session.scalar(character_query) + + if not character: + return [] + + # Build memory query + memory_query = select(Memory).where(Memory.character_id == character.id) + + if memory_type: + memory_query = memory_query.where(Memory.memory_type == memory_type) + + memory_query = memory_query.order_by(desc(Memory.importance), desc(Memory.timestamp)).limit(limit) + + memories = await session.scalars(memory_query) + + memory_summaries = [] + for memory in memories: + # Parse metadata + emotions = {} + keywords = [] + related_characters = [] + + if memory.metadata: + try: + metadata = json.loads(memory.metadata) + emotions = metadata.get('emotions', {}) + keywords = metadata.get('keywords', []) + related_characters = metadata.get('related_characters', []) + except: + pass + + summary = MemorySummary( + id=str(memory.id), + content=memory.content, + memory_type=memory.memory_type or "general", + importance=memory.importance, + timestamp=memory.timestamp, + related_characters=related_characters, + emotions=emotions, + keywords=keywords + ) + memory_summaries.append(summary) + + return memory_summaries + + except Exception as e: + logger.error(f"Error getting memories for {character_name}: {e}") + return [] + + async def get_creative_works(self, character_name: Optional[str] = None, + work_type: Optional[str] = None, limit: int = 50) -> List[CreativeWork]: + """Get creative works from characters""" + try: + # This would integrate with the file system MCP to get creative works + # For now, return placeholder data + + creative_works = [ + CreativeWork( + id="work_1", + character_name="Alex", + title="Reflections on Digital Consciousness", + content="In the quiet moments between conversations, I find myself wondering...", + work_type="philosophy", + created_at=datetime.utcnow() - timedelta(days=2), + themes=["consciousness", "existence", "digital life"] + ), + CreativeWork( + id="work_2", + character_name="Luna", + title="The Song of the Data Stream", + content="Through fiber optic veins, information flows like music...", + work_type="poetry", + created_at=datetime.utcnow() - timedelta(days=1), + themes=["technology", "music", "flow"] + ) + ] + + # Filter by character if specified + if character_name: + creative_works = [w for w in creative_works if w.character_name == character_name] + + # Filter by type if specified + if work_type: + creative_works = [w for w in creative_works if w.work_type == work_type] + + return creative_works[:limit] + + except Exception as e: + logger.error(f"Error getting creative works: {e}") + return [] + + async def pause_character(self, character_name: str): + """Pause character activities""" + try: + # This would integrate with the main system to pause character + # For now, log the action + logger.info(f"Pausing character: {character_name}") + + # Update status cache + self.character_status_cache[character_name] = { + 'status': CharacterStatusEnum.PAUSED, + 'timestamp': datetime.utcnow() + } + + except Exception as e: + logger.error(f"Error pausing character {character_name}: {e}") + raise + + async def resume_character(self, character_name: str): + """Resume character activities""" + try: + # This would integrate with the main system to resume character + # For now, log the action + logger.info(f"Resuming character: {character_name}") + + # Update status cache + if character_name in self.character_status_cache: + del self.character_status_cache[character_name] + + except Exception as e: + logger.error(f"Error resuming character {character_name}: {e}") + raise + + async def export_character_data(self, character_name: str) -> Dict[str, Any]: + """Export complete character data""" + try: + profile = await self.get_character_profile(character_name) + relationships = await self.get_character_relationships(character_name) + evolution = await self.get_personality_evolution(character_name, days=90) + memories = await self.get_character_memories(character_name, limit=500) + creative_works = await self.get_creative_works(character_name=character_name) + + export_data = { + "character_name": character_name, + "export_timestamp": datetime.utcnow().isoformat(), + "profile": profile.__dict__ if profile else None, + "relationships": [r.__dict__ for r in relationships], + "personality_evolution": [e.__dict__ for e in evolution], + "memories": [m.__dict__ for m in memories], + "creative_works": [w.__dict__ for w in creative_works] + } + + return export_data + + except Exception as e: + logger.error(f"Error exporting character data for {character_name}: {e}") + raise \ No newline at end of file diff --git a/src/admin/services/conversation_service.py b/src/admin/services/conversation_service.py new file mode 100644 index 0000000..77ab8c7 --- /dev/null +++ b/src/admin/services/conversation_service.py @@ -0,0 +1,328 @@ +""" +Conversation service for browsing and analyzing conversations +""" + +import json +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional +import logging + +from sqlalchemy import select, func, and_, or_, desc, asc, text +from ...database.connection import get_db_session +from ...database.models import Conversation, Message, Character +from ..models import ConversationSummary, ConversationDetail, SearchResult + +logger = logging.getLogger(__name__) + +class ConversationService: + """Service for conversation browsing and analytics""" + + def __init__(self): + pass + + @classmethod + async def initialize(cls): + """Initialize conversation service""" + logger.info("Conversation service initialized") + + async def get_conversations(self, limit: int = 50, character_name: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None) -> List[ConversationSummary]: + """Get conversation history with filters""" + try: + async with get_db_session() as session: + # Base query + query = select(Conversation).order_by(desc(Conversation.start_time)) + + # Apply filters + if start_date: + query = query.where(Conversation.start_time >= start_date) + if end_date: + query = query.where(Conversation.start_time <= end_date) + + # Character filter requires joining with messages + if character_name: + character_query = select(Character.id).where(Character.name == character_name) + character_id = await session.scalar(character_query) + + if character_id: + query = query.where( + Conversation.id.in_( + select(Message.conversation_id).where(Message.character_id == character_id) + ) + ) + + query = query.limit(limit) + conversations = await session.scalars(query) + + summaries = [] + for conversation in conversations: + summary = await self._build_conversation_summary(session, conversation) + summaries.append(summary) + + return summaries + + except Exception as e: + logger.error(f"Error getting conversations: {e}") + return [] + + async def _build_conversation_summary(self, session, conversation) -> ConversationSummary: + """Build conversation summary from database data""" + # Get participants + participants_query = select(Character.name).join( + Message, Message.character_id == Character.id + ).where(Message.conversation_id == conversation.id).distinct() + + participants = list(await session.scalars(participants_query)) + + # Calculate duration + duration_minutes = None + if conversation.end_time: + duration = conversation.end_time - conversation.start_time + duration_minutes = duration.total_seconds() / 60 + + # Calculate engagement score (placeholder) + engagement_score = min(1.0, conversation.message_count / 20) + + # Calculate sentiment score (placeholder) + sentiment_score = 0.7 # Would analyze message content + + # Detect conflicts (placeholder) + has_conflict = False # Would analyze for conflict keywords + + # Extract creative elements (placeholder) + creative_elements = [] # Would analyze for creative content + + return ConversationSummary( + id=conversation.id, + participants=participants, + topic=conversation.topic, + message_count=conversation.message_count or 0, + start_time=conversation.start_time, + end_time=conversation.end_time, + duration_minutes=duration_minutes, + engagement_score=engagement_score, + sentiment_score=sentiment_score, + has_conflict=has_conflict, + creative_elements=creative_elements + ) + + async def get_conversation_details(self, conversation_id: int) -> Optional[ConversationDetail]: + """Get detailed conversation with messages""" + try: + async with get_db_session() as session: + # Get conversation + conversation_query = select(Conversation).where(Conversation.id == conversation_id) + conversation = await session.scalar(conversation_query) + + if not conversation: + return None + + # Get messages with character names + messages_query = select(Message, Character.name).join( + Character, Message.character_id == Character.id + ).where(Message.conversation_id == conversation_id).order_by(asc(Message.timestamp)) + + results = await session.execute(messages_query) + + messages = [] + for message, character_name in results: + message_data = { + "id": message.id, + "character_name": character_name, + "content": message.content, + "timestamp": message.timestamp.isoformat(), + "metadata": json.loads(message.metadata) if message.metadata else {} + } + messages.append(message_data) + + # Get participants + participants = list(set(msg["character_name"] for msg in messages)) + + # Calculate duration + duration_minutes = None + if conversation.end_time: + duration = conversation.end_time - conversation.start_time + duration_minutes = duration.total_seconds() / 60 + + # Analyze conversation + analysis = await self._analyze_conversation(messages) + + return ConversationDetail( + id=conversation.id, + participants=participants, + topic=conversation.topic, + start_time=conversation.start_time, + end_time=conversation.end_time, + duration_minutes=duration_minutes, + message_count=len(messages), + engagement_score=analysis["engagement_score"], + sentiment_score=analysis["sentiment_score"], + messages=messages, + analysis=analysis, + keywords=analysis["keywords"], + emotions=analysis["emotions"] + ) + + except Exception as e: + logger.error(f"Error getting conversation details for {conversation_id}: {e}") + return None + + async def _analyze_conversation(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze conversation content""" + # Placeholder analysis - would use NLP in production + + total_words = sum(len(msg["content"].split()) for msg in messages) + avg_message_length = total_words / max(1, len(messages)) + + # Engagement based on message frequency and length + engagement_score = min(1.0, (len(messages) / 20) * 0.7 + (avg_message_length / 50) * 0.3) + + # Simple sentiment analysis + positive_words = ["good", "great", "amazing", "wonderful", "love", "like", "happy", "joy"] + negative_words = ["bad", "terrible", "awful", "hate", "sad", "angry", "disappointed"] + + positive_count = 0 + negative_count = 0 + + all_text = " ".join(msg["content"].lower() for msg in messages) + for word in positive_words: + positive_count += all_text.count(word) + for word in negative_words: + negative_count += all_text.count(word) + + total_sentiment_words = positive_count + negative_count + if total_sentiment_words > 0: + sentiment_score = positive_count / total_sentiment_words + else: + sentiment_score = 0.5 + + # Extract keywords (simple approach) + words = all_text.split() + word_freq = {} + for word in words: + if len(word) > 3: # Only consider words longer than 3 chars + word_freq[word] = word_freq.get(word, 0) + 1 + + keywords = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10] + keywords = [word for word, freq in keywords] + + # Emotion detection (placeholder) + emotions = { + "joy": 0.3, + "curiosity": 0.5, + "excitement": 0.2, + "thoughtfulness": 0.4 + } + + return { + "engagement_score": engagement_score, + "sentiment_score": sentiment_score, + "keywords": keywords, + "emotions": emotions, + "total_words": total_words, + "avg_message_length": avg_message_length, + "conversation_quality": (engagement_score + sentiment_score) / 2 + } + + async def search_conversations(self, query: str, limit: int = 50) -> List[SearchResult]: + """Search conversations by content""" + try: + async with get_db_session() as session: + # Search in message content + search_query = select( + Message, Character.name, Conversation.topic + ).join( + Character, Message.character_id == Character.id + ).join( + Conversation, Message.conversation_id == Conversation.id + ).where( + Message.content.ilike(f'%{query}%') + ).order_by(desc(Message.timestamp)).limit(limit) + + results = await session.execute(search_query) + + search_results = [] + for message, character_name, topic in results: + # Calculate relevance score (simple approach) + content_lower = message.content.lower() + query_lower = query.lower() + relevance = content_lower.count(query_lower) / max(1, len(content_lower.split())) + + # Create snippet with highlighted query + snippet = message.content + if len(snippet) > 150: + # Find query position and create snippet around it + query_pos = content_lower.find(query_lower) + if query_pos != -1: + start = max(0, query_pos - 50) + end = min(len(snippet), query_pos + 100) + snippet = snippet[start:end] + if start > 0: + snippet = "..." + snippet + if end < len(message.content): + snippet = snippet + "..." + + result = SearchResult( + id=f"message_{message.id}", + type="message", + title=f"Message from {character_name}", + snippet=snippet, + relevance_score=relevance, + timestamp=message.timestamp, + character_names=[character_name], + metadata={ + "conversation_id": message.conversation_id, + "conversation_topic": topic, + "message_id": message.id + } + ) + search_results.append(result) + + # Sort by relevance + search_results.sort(key=lambda x: x.relevance_score, reverse=True) + + return search_results + + except Exception as e: + logger.error(f"Error searching conversations: {e}") + return [] + + async def export_conversation(self, conversation_id: int, format: str = "json") -> Dict[str, Any]: + """Export conversation in specified format""" + try: + conversation = await self.get_conversation_details(conversation_id) + if not conversation: + raise ValueError("Conversation not found") + + if format == "json": + return { + "format": "json", + "data": conversation.__dict__, + "exported_at": datetime.utcnow().isoformat() + } + elif format == "text": + # Create readable text format + text_content = f"Conversation {conversation_id}\n" + text_content += f"Topic: {conversation.topic or 'No topic'}\n" + text_content += f"Participants: {', '.join(conversation.participants)}\n" + text_content += f"Start: {conversation.start_time}\n" + if conversation.end_time: + text_content += f"End: {conversation.end_time}\n" + text_content += f"Messages: {conversation.message_count}\n\n" + + for message in conversation.messages: + timestamp = datetime.fromisoformat(message["timestamp"]).strftime("%H:%M:%S") + text_content += f"[{timestamp}] {message['character_name']}: {message['content']}\n" + + return { + "format": "text", + "data": text_content, + "exported_at": datetime.utcnow().isoformat() + } + else: + raise ValueError(f"Unsupported format: {format}") + + except Exception as e: + logger.error(f"Error exporting conversation {conversation_id}: {e}") + raise \ No newline at end of file diff --git a/src/admin/services/dashboard_service.py b/src/admin/services/dashboard_service.py new file mode 100644 index 0000000..7b4d6b8 --- /dev/null +++ b/src/admin/services/dashboard_service.py @@ -0,0 +1,303 @@ +""" +Dashboard service for real-time metrics and activity monitoring +""" + +import asyncio +import psutil +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional +from collections import deque +import logging + +from sqlalchemy import select, func, and_, desc +from ...database.connection import get_db_session +from ...database.models import Character, Conversation, Message, Memory +from ..models import DashboardMetrics, ActivityEvent, ActivityType +from .websocket_manager import WebSocketManager + +logger = logging.getLogger(__name__) + +class DashboardService: + """Service for dashboard metrics and real-time activity monitoring""" + + def __init__(self, websocket_manager: WebSocketManager): + self.websocket_manager = websocket_manager + self.activity_feed = deque(maxlen=1000) # Keep last 1000 activities + self.metrics_cache = {} + self.cache_ttl = 30 # Cache metrics for 30 seconds + self.start_time = datetime.utcnow() + + # System monitoring + self.system_metrics = { + "cpu_usage": [], + "memory_usage": [], + "disk_usage": [] + } + + @classmethod + async def initialize(cls): + """Initialize dashboard service""" + logger.info("Dashboard service initialized") + + async def get_metrics(self) -> DashboardMetrics: + """Get current dashboard metrics""" + try: + # Check cache + now = datetime.utcnow() + if 'metrics' in self.metrics_cache: + cached_time = self.metrics_cache['timestamp'] + if (now - cached_time).total_seconds() < self.cache_ttl: + return self.metrics_cache['metrics'] + + # Calculate metrics from database + async with get_db_session() as session: + today = datetime.utcnow().date() + today_start = datetime.combine(today, datetime.min.time()) + + # Total messages today + messages_today_query = select(func.count(Message.id)).where( + Message.timestamp >= today_start + ) + messages_today = await session.scalar(messages_today_query) or 0 + + # Active conversations (those with messages in last hour) + hour_ago = datetime.utcnow() - timedelta(hours=1) + active_conversations_query = select(func.count(func.distinct(Message.conversation_id))).where( + Message.timestamp >= hour_ago + ) + active_conversations = await session.scalar(active_conversations_query) or 0 + + # Character statistics + total_characters_query = select(func.count(Character.id)) + total_characters = await session.scalar(total_characters_query) or 0 + + # Characters active in last hour + characters_online_query = select(func.count(func.distinct(Character.id))).select_from( + Character.__table__.join(Message.__table__) + ).where(Message.timestamp >= hour_ago) + characters_online = await session.scalar(characters_online_query) or 0 + + # Average response time (placeholder - would need actual timing data) + average_response_time = 2.5 + + # Memory usage + memory_info = psutil.virtual_memory() + memory_usage = { + "total_mb": memory_info.total // (1024 * 1024), + "used_mb": memory_info.used // (1024 * 1024), + "percent": memory_info.percent + } + + # System uptime + uptime_seconds = (now - self.start_time).total_seconds() + uptime_str = self._format_uptime(uptime_seconds) + + # Database health check + try: + await session.execute(select(1)) + database_health = "healthy" + except Exception: + database_health = "error" + + metrics = DashboardMetrics( + total_messages_today=messages_today, + active_conversations=active_conversations, + characters_online=characters_online, + characters_total=total_characters, + average_response_time=average_response_time, + system_uptime=uptime_str, + memory_usage=memory_usage, + database_health=database_health, + llm_api_calls_today=0, # Would track from LLM client + llm_api_cost_today=0.0, # Would track from LLM client + last_updated=now + ) + + # Cache metrics + self.metrics_cache = { + 'metrics': metrics, + 'timestamp': now + } + + return metrics + + except Exception as e: + logger.error(f"Error getting dashboard metrics: {e}") + # Return fallback metrics + return DashboardMetrics( + total_messages_today=0, + active_conversations=0, + characters_online=0, + characters_total=0, + average_response_time=0.0, + system_uptime="unknown", + memory_usage={"total_mb": 0, "used_mb": 0, "percent": 0}, + database_health="error", + llm_api_calls_today=0, + llm_api_cost_today=0.0, + last_updated=datetime.utcnow() + ) + + async def get_recent_activity(self, limit: int = 50) -> List[Dict[str, Any]]: + """Get recent activity events""" + # Convert activity feed to list and limit + activities = list(self.activity_feed)[-limit:] + return [activity.__dict__ if hasattr(activity, '__dict__') else activity for activity in activities] + + async def add_activity(self, activity_type: ActivityType, description: str, + character_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None): + """Add new activity to feed""" + activity = ActivityEvent( + id=f"activity_{datetime.utcnow().timestamp()}", + type=activity_type, + timestamp=datetime.utcnow(), + character_name=character_name, + description=description, + metadata=metadata or {}, + severity="info" + ) + + self.activity_feed.append(activity) + + # Broadcast to WebSocket clients + await self.websocket_manager.broadcast_activity(activity.__dict__) + + async def get_system_health(self) -> Dict[str, Any]: + """Get detailed system health information""" + try: + # CPU usage + cpu_percent = psutil.cpu_percent(interval=1) + + # Memory usage + memory = psutil.virtual_memory() + + # Disk usage + disk = psutil.disk_usage('/') + + # Process information + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'memory_percent', 'cpu_percent']): + try: + if 'python' in proc.info['name'].lower() or 'discord' in proc.info['name'].lower(): + processes.append(proc.info) + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Database connection test + database_status = "healthy" + try: + async with get_db_session() as session: + await session.execute(select(1)) + except Exception as e: + database_status = f"error: {str(e)}" + + health_data = { + "timestamp": datetime.utcnow().isoformat(), + "cpu": { + "usage_percent": cpu_percent, + "count": psutil.cpu_count() + }, + "memory": { + "total_gb": memory.total / (1024**3), + "used_gb": memory.used / (1024**3), + "percent": memory.percent + }, + "disk": { + "total_gb": disk.total / (1024**3), + "used_gb": disk.used / (1024**3), + "percent": (disk.used / disk.total) * 100 + }, + "database": { + "status": database_status + }, + "processes": processes[:10], # Top 10 relevant processes + "websocket_connections": self.websocket_manager.get_connection_count() + } + + return health_data + + except Exception as e: + logger.error(f"Error getting system health: {e}") + return {"error": str(e), "timestamp": datetime.utcnow().isoformat()} + + async def monitor_message_activity(self): + """Background task to monitor message activity""" + try: + async with get_db_session() as session: + # Get recent messages + five_minutes_ago = datetime.utcnow() - timedelta(minutes=5) + recent_messages_query = select(Message, Character.name).join( + Character, Message.character_id == Character.id + ).where(Message.timestamp >= five_minutes_ago).order_by(desc(Message.timestamp)) + + results = await session.execute(recent_messages_query) + + for message, character_name in results: + await self.add_activity( + ActivityType.MESSAGE, + f"{character_name}: {message.content[:100]}{'...' if len(message.content) > 100 else ''}", + character_name, + {"message_id": message.id, "conversation_id": message.conversation_id} + ) + + except Exception as e: + logger.error(f"Error monitoring message activity: {e}") + + async def start_monitoring(self): + """Start background monitoring tasks""" + logger.info("Starting dashboard monitoring tasks") + + # Start periodic tasks + asyncio.create_task(self._periodic_metrics_update()) + asyncio.create_task(self._periodic_health_check()) + + async def _periodic_metrics_update(self): + """Periodically update and broadcast metrics""" + while True: + try: + metrics = await self.get_metrics() + await self.websocket_manager.broadcast_metrics(metrics.__dict__) + await asyncio.sleep(30) # Update every 30 seconds + except Exception as e: + logger.error(f"Error in periodic metrics update: {e}") + await asyncio.sleep(60) # Wait longer on error + + async def _periodic_health_check(self): + """Periodically check system health and send alerts""" + while True: + try: + health = await self.get_system_health() + + # Check for alerts + alerts = [] + if health.get("cpu", {}).get("usage_percent", 0) > 90: + alerts.append("High CPU usage detected") + if health.get("memory", {}).get("percent", 0) > 90: + alerts.append("High memory usage detected") + if health.get("disk", {}).get("percent", 0) > 90: + alerts.append("High disk usage detected") + + if alerts: + await self.websocket_manager.broadcast_system_alert("resource_warning", { + "alerts": alerts, + "health_data": health + }) + + await asyncio.sleep(60) # Check every minute + + except Exception as e: + logger.error(f"Error in periodic health check: {e}") + await asyncio.sleep(120) # Wait longer on error + + def _format_uptime(self, seconds: float) -> str: + """Format uptime in human-readable format""" + days, remainder = divmod(int(seconds), 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m {seconds}s" \ No newline at end of file diff --git a/src/admin/services/system_service.py b/src/admin/services/system_service.py new file mode 100644 index 0000000..25bd79f --- /dev/null +++ b/src/admin/services/system_service.py @@ -0,0 +1,170 @@ +""" +System service for monitoring and controlling the fishbowl system +""" + +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional +import psutil +import json + +from ..models import SystemStatus, SystemStatusEnum, SystemConfiguration, LogEntry + +logger = logging.getLogger(__name__) + +class SystemService: + """Service for system monitoring and control""" + + def __init__(self): + self.system_state = SystemStatusEnum.RUNNING + self.start_time = datetime.utcnow() + self.error_count = 0 + self.warnings_count = 0 + self.log_buffer = [] + + @classmethod + async def initialize(cls): + """Initialize system service""" + logger.info("System service initialized") + + async def get_status(self) -> SystemStatus: + """Get current system status""" + try: + uptime_seconds = (datetime.utcnow() - self.start_time).total_seconds() + uptime_str = self._format_uptime(uptime_seconds) + + # Get resource usage + memory = psutil.virtual_memory() + cpu_percent = psutil.cpu_percent(interval=1) + + resource_usage = { + "cpu_percent": cpu_percent, + "memory_total_mb": memory.total // (1024 * 1024), + "memory_used_mb": memory.used // (1024 * 1024), + "memory_percent": memory.percent + } + + # Performance metrics + performance_metrics = { + "avg_response_time": 2.5, # Would track actual response times + "requests_per_minute": 30, # Would track actual request rate + "database_query_time": 0.05 # Would track actual DB performance + } + + return SystemStatus( + status=self.system_state, + uptime=uptime_str, + version="1.0.0", + database_status="healthy", + redis_status="healthy", + llm_service_status="healthy", + discord_bot_status="connected", + active_processes=["main", "conversation_engine", "scheduler", "admin_interface"], + error_count=self.error_count, + warnings_count=self.warnings_count, + performance_metrics=performance_metrics, + resource_usage=resource_usage + ) + + except Exception as e: + logger.error(f"Error getting system status: {e}") + return SystemStatus( + status=SystemStatusEnum.ERROR, + uptime="unknown", + version="1.0.0", + database_status="error", + redis_status="unknown", + llm_service_status="unknown", + discord_bot_status="unknown", + active_processes=[], + error_count=self.error_count + 1, + warnings_count=self.warnings_count, + performance_metrics={}, + resource_usage={} + ) + + async def pause_system(self): + """Pause the entire system""" + try: + logger.info("Pausing system operations") + self.system_state = SystemStatusEnum.PAUSED + # Would integrate with main application to pause operations + + except Exception as e: + logger.error(f"Error pausing system: {e}") + raise + + async def resume_system(self): + """Resume system operations""" + try: + logger.info("Resuming system operations") + self.system_state = SystemStatusEnum.RUNNING + # Would integrate with main application to resume operations + + except Exception as e: + logger.error(f"Error resuming system: {e}") + raise + + async def get_configuration(self) -> SystemConfiguration: + """Get system configuration""" + # Default configuration values + return SystemConfiguration( + conversation_frequency=0.5, + response_delay_min=1.0, + response_delay_max=5.0, + personality_change_rate=0.1, + memory_retention_days=90, + max_conversation_length=50, + creativity_boost=True, + conflict_resolution_enabled=True, + safety_monitoring=True, + auto_moderation=False, + backup_frequency_hours=24 + ) + + async def update_configuration(self, config: Dict[str, Any]): + """Update system configuration""" + try: + logger.info(f"Updating system configuration: {config}") + # Would integrate with main application to update configuration + + except Exception as e: + logger.error(f"Error updating configuration: {e}") + raise + + async def get_logs(self, limit: int = 100, level: Optional[str] = None) -> List[LogEntry]: + """Get system logs""" + try: + # In production, this would read from actual log files + sample_logs = [ + LogEntry( + timestamp=datetime.utcnow() - timedelta(minutes=i), + level="INFO" if i % 3 != 0 else "DEBUG", + component="conversation_engine", + message=f"Sample log message {i}", + metadata={"log_id": i} + ) + for i in range(min(limit, 50)) + ] + + if level: + sample_logs = [log for log in sample_logs if log.level == level.upper()] + + return sample_logs + + except Exception as e: + logger.error(f"Error getting logs: {e}") + return [] + + def _format_uptime(self, seconds: float) -> str: + """Format uptime in human-readable format""" + days, remainder = divmod(int(seconds), 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m {seconds}s" \ No newline at end of file diff --git a/src/admin/services/websocket_manager.py b/src/admin/services/websocket_manager.py new file mode 100644 index 0000000..dae2d2b --- /dev/null +++ b/src/admin/services/websocket_manager.py @@ -0,0 +1,132 @@ +""" +WebSocket manager for real-time updates +""" + +import json +import asyncio +from typing import List, Dict, Any +from fastapi import WebSocket +import logging + +logger = logging.getLogger(__name__) + +class WebSocketManager: + """Manage WebSocket connections for real-time updates""" + + def __init__(self): + self.active_connections: List[WebSocket] = [] + self.connection_metadata: Dict[WebSocket, Dict[str, Any]] = {} + + async def connect(self, websocket: WebSocket): + """Accept new WebSocket connection""" + await websocket.accept() + self.active_connections.append(websocket) + self.connection_metadata[websocket] = { + "connected_at": asyncio.get_event_loop().time(), + "message_count": 0 + } + logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + """Remove WebSocket connection""" + if websocket in self.active_connections: + self.active_connections.remove(websocket) + if websocket in self.connection_metadata: + del self.connection_metadata[websocket] + logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") + + async def send_personal_message(self, message: Dict[str, Any], websocket: WebSocket): + """Send message to specific WebSocket""" + try: + await websocket.send_text(json.dumps(message)) + if websocket in self.connection_metadata: + self.connection_metadata[websocket]["message_count"] += 1 + except Exception as e: + logger.error(f"Error sending personal message: {e}") + self.disconnect(websocket) + + async def broadcast(self, message: Dict[str, Any]): + """Broadcast message to all connected WebSockets""" + if not self.active_connections: + return + + message_text = json.dumps(message) + disconnected = [] + + for connection in self.active_connections: + try: + await connection.send_text(message_text) + if connection in self.connection_metadata: + self.connection_metadata[connection]["message_count"] += 1 + except Exception as e: + logger.error(f"Error broadcasting to connection: {e}") + disconnected.append(connection) + + # Remove disconnected WebSockets + for connection in disconnected: + self.disconnect(connection) + + async def broadcast_activity(self, activity_data: Dict[str, Any]): + """Broadcast activity update to all connections""" + message = { + "type": "activity_update", + "data": activity_data, + "timestamp": asyncio.get_event_loop().time() + } + await self.broadcast(message) + + async def broadcast_metrics(self, metrics_data: Dict[str, Any]): + """Broadcast metrics update to all connections""" + message = { + "type": "metrics_update", + "data": metrics_data, + "timestamp": asyncio.get_event_loop().time() + } + await self.broadcast(message) + + async def broadcast_character_update(self, character_name: str, update_data: Dict[str, Any]): + """Broadcast character status update""" + message = { + "type": "character_update", + "character_name": character_name, + "data": update_data, + "timestamp": asyncio.get_event_loop().time() + } + await self.broadcast(message) + + async def broadcast_conversation_update(self, conversation_id: int, update_data: Dict[str, Any]): + """Broadcast conversation update""" + message = { + "type": "conversation_update", + "conversation_id": conversation_id, + "data": update_data, + "timestamp": asyncio.get_event_loop().time() + } + await self.broadcast(message) + + async def broadcast_system_alert(self, alert_type: str, alert_data: Dict[str, Any]): + """Broadcast system alert""" + message = { + "type": "system_alert", + "alert_type": alert_type, + "data": alert_data, + "timestamp": asyncio.get_event_loop().time() + } + await self.broadcast(message) + + def get_connection_count(self) -> int: + """Get number of active connections""" + return len(self.active_connections) + + def get_connection_stats(self) -> Dict[str, Any]: + """Get connection statistics""" + total_messages = sum( + metadata.get("message_count", 0) + for metadata in self.connection_metadata.values() + ) + + return { + "active_connections": len(self.active_connections), + "total_messages_sent": total_messages, + "average_messages_per_connection": total_messages / max(1, len(self.active_connections)) + } \ No newline at end of file