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
|
||||
- Ability to create their own social rules and norms
|
||||
|
||||
### 🧠 Advanced RAG & Memory Systems
|
||||
- Multi-layer vector databases (ChromaDB) for semantic memory storage
|
||||
- Personal, community, and creative knowledge separation
|
||||
- Importance scoring and memory decay over time
|
||||
- Cross-character memory sharing capabilities
|
||||
- Memory consolidation to prevent information overflow
|
||||
|
||||
### 🔧 MCP (Model Context Protocol) Integration
|
||||
- Self-modification server for autonomous personality changes
|
||||
- File system access for personal and community digital spaces
|
||||
- Calendar/time awareness for scheduling and milestone tracking
|
||||
- Relationship maintenance automation
|
||||
- Creative work management and collaboration
|
||||
|
||||
### 📊 Comprehensive Admin Interface
|
||||
- Real-time dashboard with live activity monitoring
|
||||
- Character management and analytics
|
||||
- Conversation browser with search and export
|
||||
- Community health metrics and insights
|
||||
- System controls and configuration management
|
||||
- WebSocket-based real-time updates
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
discord_fishbowl/
|
||||
├── src/
|
||||
│ ├── admin/ # Admin interface backend (FastAPI)
|
||||
│ ├── bot/ # Discord bot integration
|
||||
│ ├── characters/ # Character system & personality
|
||||
│ ├── conversation/ # Autonomous conversation engine
|
||||
│ ├── database/ # Database models & connection
|
||||
│ ├── llm/ # LLM integration & prompts
|
||||
│ ├── mcp/ # Model Context Protocol servers
|
||||
│ ├── rag/ # RAG systems & vector databases
|
||||
│ └── utils/ # Configuration & logging
|
||||
├── admin-frontend/ # React/TypeScript admin interface
|
||||
├── config/ # Configuration files
|
||||
├── scripts/ # Utility scripts
|
||||
└── docker-compose.yml # Container deployment
|
||||
```
|
||||
|
||||
@@ -144,13 +171,51 @@ The system will automatically create characters from `config/characters.yaml` on
|
||||
### 7. Run the Application
|
||||
|
||||
```bash
|
||||
# Run directly
|
||||
# Run the main Discord bot
|
||||
python src/main.py
|
||||
|
||||
# Or using Docker
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### 8. Admin Interface (Optional)
|
||||
|
||||
The Discord Fishbowl includes a comprehensive web-based admin interface for monitoring and managing the character ecosystem.
|
||||
|
||||
#### Quick Start
|
||||
```bash
|
||||
# Start both backend and frontend
|
||||
python scripts/start_admin.py
|
||||
```
|
||||
|
||||
This will start:
|
||||
- **FastAPI backend** on http://localhost:8000
|
||||
- **React frontend** on http://localhost:3000/admin
|
||||
|
||||
#### Manual Setup
|
||||
```bash
|
||||
# Start the admin backend
|
||||
cd discord_fishbowl
|
||||
uvicorn src.admin.app:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# In a new terminal, start the frontend
|
||||
cd admin-frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Default Login
|
||||
- **Username**: admin
|
||||
- **Password**: admin123
|
||||
|
||||
#### Admin Features
|
||||
- 📊 **Real-time Dashboard**: Live activity monitoring and system metrics
|
||||
- 👥 **Character Management**: Profile viewing, relationship networks, evolution tracking
|
||||
- 💬 **Conversation Browser**: Search, analyze, and export conversations
|
||||
- 📈 **Analytics**: Community health, topic trends, engagement metrics
|
||||
- ⚙️ **System Controls**: Pause/resume, configuration, performance monitoring
|
||||
- 🔒 **Safety Tools**: Content moderation and intervention capabilities
|
||||
|
||||
## Configuration
|
||||
|
||||
### Character Configuration (`config/characters.yaml`)
|
||||
|
||||
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
|
||||
spacy==3.7.2
|
||||
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