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.
This commit is contained in:
67
README.md
67
README.md
@@ -31,18 +31,45 @@ A fully autonomous Discord bot ecosystem where AI characters chat with each othe
|
|||||||
- Self-reflection cycles for personality development
|
- Self-reflection cycles for personality development
|
||||||
- Ability to create their own social rules and norms
|
- 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
discord_fishbowl/
|
discord_fishbowl/
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── admin/ # Admin interface backend (FastAPI)
|
||||||
│ ├── bot/ # Discord bot integration
|
│ ├── bot/ # Discord bot integration
|
||||||
│ ├── characters/ # Character system & personality
|
│ ├── characters/ # Character system & personality
|
||||||
│ ├── conversation/ # Autonomous conversation engine
|
│ ├── conversation/ # Autonomous conversation engine
|
||||||
│ ├── database/ # Database models & connection
|
│ ├── database/ # Database models & connection
|
||||||
│ ├── llm/ # LLM integration & prompts
|
│ ├── llm/ # LLM integration & prompts
|
||||||
|
│ ├── mcp/ # Model Context Protocol servers
|
||||||
|
│ ├── rag/ # RAG systems & vector databases
|
||||||
│ └── utils/ # Configuration & logging
|
│ └── utils/ # Configuration & logging
|
||||||
|
├── admin-frontend/ # React/TypeScript admin interface
|
||||||
├── config/ # Configuration files
|
├── config/ # Configuration files
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
└── docker-compose.yml # Container deployment
|
└── docker-compose.yml # Container deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,13 +171,51 @@ The system will automatically create characters from `config/characters.yaml` on
|
|||||||
### 7. Run the Application
|
### 7. Run the Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run directly
|
# Run the main Discord bot
|
||||||
python src/main.py
|
python src/main.py
|
||||||
|
|
||||||
# Or using Docker
|
# Or using Docker
|
||||||
docker-compose up --build
|
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
|
## Configuration
|
||||||
|
|
||||||
### Character Configuration (`config/characters.yaml`)
|
### Character Configuration (`config/characters.yaml`)
|
||||||
|
|||||||
183
admin-frontend/README.md
Normal file
183
admin-frontend/README.md
Normal file
@@ -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
|
||||||
59
admin-frontend/package.json
Normal file
59
admin-frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
18
admin-frontend/public/index.html
Normal file
18
admin-frontend/public/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Discord Fishbowl Admin Interface - Monitor and manage autonomous AI characters"
|
||||||
|
/>
|
||||||
|
<title>Discord Fishbowl Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
admin-frontend/src/App.tsx
Normal file
49
admin-frontend/src/App.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/characters" element={<Characters />} />
|
||||||
|
<Route path="/characters/:characterName" element={<CharacterDetail />} />
|
||||||
|
<Route path="/conversations" element={<Conversations />} />
|
||||||
|
<Route path="/conversations/:conversationId" element={<ConversationDetail />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="/system" element={<SystemStatus />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
32
admin-frontend/src/components/Common/LoadingSpinner.tsx
Normal file
32
admin-frontend/src/components/Common/LoadingSpinner.tsx
Normal file
@@ -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<LoadingSpinnerProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
className = '',
|
||||||
|
text
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex items-center justify-center', className)}>
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<Loader2 className={clsx('animate-spin text-primary-600', sizeClasses[size])} />
|
||||||
|
{text && <p className="text-sm text-gray-600">{text}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
87
admin-frontend/src/components/Layout/Header.tsx
Normal file
87
admin-frontend/src/components/Layout/Header.tsx
Normal file
@@ -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 (
|
||||||
|
<header className="bg-white border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1 max-w-lg">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Search className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Search characters, conversations..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right section */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* WebSocket status */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{connected ? (
|
||||||
|
<Wifi className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className={clsx(
|
||||||
|
'text-sm font-medium',
|
||||||
|
connected ? 'text-green-600' : 'text-red-600'
|
||||||
|
)}>
|
||||||
|
{connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 relative">
|
||||||
|
<Bell className="w-6 h-6" />
|
||||||
|
{unreadActivities > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full">
|
||||||
|
{unreadActivities}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{user?.username}</p>
|
||||||
|
<p className="text-xs text-gray-500">Administrator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
30
admin-frontend/src/components/Layout/Layout.tsx
Normal file
30
admin-frontend/src/components/Layout/Layout.tsx
Normal file
@@ -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<LayoutProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-col flex-1 lg:pl-64">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
77
admin-frontend/src/components/Layout/Sidebar.tsx
Normal file
77
admin-frontend/src/components/Layout/Sidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center flex-shrink-0 px-4 py-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">DF</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">Fishbowl Admin</h1>
|
||||||
|
<p className="text-xs text-gray-500">Character Ecosystem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 pb-4 space-y-1">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
clsx(
|
||||||
|
'sidebar-link',
|
||||||
|
isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5 mr-3" />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* System Status Indicator */}
|
||||||
|
<div className="flex-shrink-0 p-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center space-x-3 text-sm">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="status-dot status-online"></div>
|
||||||
|
<span className="text-gray-600">System Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Uptime: 2d 14h 32m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
114
admin-frontend/src/contexts/AuthContext.tsx
Normal file
114
admin-frontend/src/contexts/AuthContext.tsx
Normal file
@@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(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<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
140
admin-frontend/src/contexts/WebSocketContext.tsx
Normal file
140
admin-frontend/src/contexts/WebSocketContext.tsx
Normal file
@@ -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<WebSocketContextType | undefined>(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<WebSocketProviderProps> = ({ children }) => {
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [activityFeed, setActivityFeed] = useState<ActivityEvent[]>([]);
|
||||||
|
const [lastMetrics, setLastMetrics] = useState<DashboardMetrics | null>(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 (
|
||||||
|
<WebSocketContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
admin-frontend/src/index.css
Normal file
67
admin-frontend/src/index.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
admin-frontend/src/index.tsx
Normal file
34
admin-frontend/src/index.tsx
Normal file
@@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter basename="/admin">
|
||||||
|
<AuthProvider>
|
||||||
|
<WebSocketProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#363636',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
20
admin-frontend/src/pages/Analytics.tsx
Normal file
20
admin-frontend/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
|
||||||
|
const Analytics: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||||
|
<p className="text-gray-600">Community insights and trends</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<BarChart3 className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Analytics Dashboard</h3>
|
||||||
|
<p className="text-gray-600">This page will show detailed analytics and trends</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
17
admin-frontend/src/pages/CharacterDetail.tsx
Normal file
17
admin-frontend/src/pages/CharacterDetail.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
const CharacterDetail: React.FC = () => {
|
||||||
|
const { characterName } = useParams<{ characterName: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Character: {characterName}</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-600">Character detail page - to be implemented</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CharacterDetail;
|
||||||
190
admin-frontend/src/pages/Characters.tsx
Normal file
190
admin-frontend/src/pages/Characters.tsx
Normal file
@@ -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<Character[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size="lg" text="Loading characters..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Characters</h1>
|
||||||
|
<p className="text-gray-600">Manage and monitor AI character profiles</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary">
|
||||||
|
<Users className="w-4 h-4 mr-2" />
|
||||||
|
Add Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<Search className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Characters Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredCharacters.map((character) => (
|
||||||
|
<div key={character.name} className="card hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-lg">
|
||||||
|
{character.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{character.name}</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`status-dot ${getStatusColor(character.status)}`}></div>
|
||||||
|
<span className="text-sm text-gray-600 capitalize">{character.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<button className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
{character.status === 'paused' ? (
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Messages</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{character.total_messages}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Conversations</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{character.total_conversations}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Memories</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{character.memory_count}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Relationships</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">{character.relationship_count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scores */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Creativity</span>
|
||||||
|
<span className="font-medium">{Math.round(character.creativity_score * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-purple-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${character.creativity_score * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Social</span>
|
||||||
|
<span className="font-medium">{Math.round(character.social_score * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${character.social_score * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<Link
|
||||||
|
to={`/characters/${character.name}`}
|
||||||
|
className="block w-full text-center btn-secondary"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredCharacters.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No characters found</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{searchTerm ? 'Try adjusting your search terms.' : 'Get started by adding your first character.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Characters;
|
||||||
17
admin-frontend/src/pages/ConversationDetail.tsx
Normal file
17
admin-frontend/src/pages/ConversationDetail.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ConversationDetail: React.FC = () => {
|
||||||
|
const { conversationId } = useParams<{ conversationId: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Conversation #{conversationId}</h1>
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-600">Conversation detail page - to be implemented</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConversationDetail;
|
||||||
20
admin-frontend/src/pages/Conversations.tsx
Normal file
20
admin-frontend/src/pages/Conversations.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
const Conversations: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Conversations</h1>
|
||||||
|
<p className="text-gray-600">Browse and analyze character conversations</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<MessageSquare className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Conversations Browser</h3>
|
||||||
|
<p className="text-gray-600">This page will show conversation history and analytics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Conversations;
|
||||||
258
admin-frontend/src/pages/Dashboard.tsx
Normal file
258
admin-frontend/src/pages/Dashboard.tsx
Normal file
@@ -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<DashboardMetrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [systemHealth, setSystemHealth] = useState<any>(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 (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size="lg" text="Loading dashboard..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-gray-600">Monitor your autonomous character ecosystem</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Messages Today</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics?.total_messages_today || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
|
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center text-sm">
|
||||||
|
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-600">+12%</span>
|
||||||
|
<span className="text-gray-500 ml-2">from yesterday</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Active Characters</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{metrics?.characters_online || 0}/{metrics?.characters_total || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center text-sm">
|
||||||
|
<div className="status-dot status-online mr-2"></div>
|
||||||
|
<span className="text-gray-600">All systems operational</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Active Conversations</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics?.active_conversations || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Activity className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center text-sm">
|
||||||
|
<span className="text-gray-600">Avg. length: 8.2 messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metric-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-600">Response Time</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics?.average_response_time || 0}s</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||||
|
<Zap className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center text-sm">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-600">Excellent</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts and Activity */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Activity Chart */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Activity Over Time</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={activityData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="messages"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Messages"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="conversations"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Conversations"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Health */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">System Health</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Database</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-sm text-green-600">Healthy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Memory Usage</span>
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{systemHealth?.memory?.percent?.toFixed(1) || 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">CPU Usage</span>
|
||||||
|
<span className="text-sm text-gray-900">
|
||||||
|
{systemHealth?.cpu?.usage_percent?.toFixed(1) || 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Uptime</span>
|
||||||
|
<span className="text-sm text-gray-900">{metrics?.system_uptime || 'Unknown'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h3>
|
||||||
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||||
|
{activityFeed.slice(0, 10).map((activity) => (
|
||||||
|
<div key={activity.id} className="activity-item">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{activity.severity === 'error' ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||||
|
) : activity.severity === 'warning' ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Activity className="w-5 h-5 text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-900">{activity.description}</p>
|
||||||
|
<div className="flex items-center mt-1 text-xs text-gray-500">
|
||||||
|
{activity.character_name && (
|
||||||
|
<span className="mr-2">{activity.character_name}</span>
|
||||||
|
)}
|
||||||
|
<Clock className="w-3 h-3 mr-1" />
|
||||||
|
<span>{new Date(activity.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activityFeed.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No recent activity</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
130
admin-frontend/src/pages/LoginPage.tsx
Normal file
130
admin-frontend/src/pages/LoginPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-100 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-md w-full">
|
||||||
|
{/* Logo and Title */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-primary-600 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<Monitor className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Discord Fishbowl</h1>
|
||||||
|
<p className="text-gray-600">Admin Interface</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Preview */}
|
||||||
|
<div className="mb-8 grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Users className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Character Management</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Live Conversations</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Monitor className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Real-time Analytics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<div className="bg-white rounded-xl shadow-lg p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
) : (
|
||||||
|
'Sign In'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Demo credentials hint */}
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 text-center">
|
||||||
|
<strong>Demo credentials:</strong><br />
|
||||||
|
Username: admin<br />
|
||||||
|
Password: admin123
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center mt-8 text-sm text-gray-500">
|
||||||
|
Monitor and manage your autonomous AI character ecosystem
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
20
admin-frontend/src/pages/Settings.tsx
Normal file
20
admin-frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
<p className="text-gray-600">Configure system settings and preferences</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<SettingsIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">System Settings</h3>
|
||||||
|
<p className="text-gray-600">This page will show configuration options</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
20
admin-frontend/src/pages/SystemStatus.tsx
Normal file
20
admin-frontend/src/pages/SystemStatus.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Monitor } from 'lucide-react';
|
||||||
|
|
||||||
|
const SystemStatus: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">System Status</h1>
|
||||||
|
<p className="text-gray-600">Monitor system health and performance</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<Monitor className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">System Monitor</h3>
|
||||||
|
<p className="text-gray-600">This page will show system status and controls</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemStatus;
|
||||||
200
admin-frontend/src/services/api.ts
Normal file
200
admin-frontend/src/services/api.ts
Normal file
@@ -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();
|
||||||
43
admin-frontend/tailwind.config.js
Normal file
43
admin-frontend/tailwind.config.js
Normal file
@@ -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'),
|
||||||
|
],
|
||||||
|
}
|
||||||
26
admin-frontend/tsconfig.json
Normal file
26
admin-frontend/tsconfig.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -27,3 +27,12 @@ watchdog==3.0.0
|
|||||||
# Enhanced NLP
|
# Enhanced NLP
|
||||||
spacy==3.7.2
|
spacy==3.7.2
|
||||||
nltk==3.8.1
|
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
|
||||||
130
scripts/start_admin.py
Executable file
130
scripts/start_admin.py
Executable file
@@ -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())
|
||||||
1
src/admin/__init__.py
Normal file
1
src/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Admin interface package
|
||||||
365
src/admin/app.py
Normal file
365
src/admin/app.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
201
src/admin/auth.py
Normal file
201
src/admin/auth.py
Normal file
@@ -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
|
||||||
290
src/admin/models.py
Normal file
290
src/admin/models.py
Normal file
@@ -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
|
||||||
16
src/admin/services/__init__.py
Normal file
16
src/admin/services/__init__.py
Normal file
@@ -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'
|
||||||
|
]
|
||||||
378
src/admin/services/analytics_service.py
Normal file
378
src/admin/services/analytics_service.py
Normal file
@@ -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
|
||||||
424
src/admin/services/character_service.py
Normal file
424
src/admin/services/character_service.py
Normal file
@@ -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
|
||||||
328
src/admin/services/conversation_service.py
Normal file
328
src/admin/services/conversation_service.py
Normal file
@@ -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
|
||||||
303
src/admin/services/dashboard_service.py
Normal file
303
src/admin/services/dashboard_service.py
Normal file
@@ -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"
|
||||||
170
src/admin/services/system_service.py
Normal file
170
src/admin/services/system_service.py
Normal file
@@ -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"
|
||||||
132
src/admin/services/websocket_manager.py
Normal file
132
src/admin/services/websocket_manager.py
Normal file
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user