Implement comprehensive LLM provider system with global cost protection
- Add multi-provider LLM architecture supporting OpenRouter, OpenAI, Gemini, and custom providers - Implement global LLM on/off switch with default DISABLED state for cost protection - Add per-character LLM configuration with provider-specific models and settings - Create performance-optimized caching system for LLM enabled status checks - Add API key validation before enabling LLM providers to prevent broken configurations - Implement audit logging for all LLM enable/disable actions for cost accountability - Create comprehensive admin UI with prominent cost warnings and confirmation dialogs - Add visual indicators in character list for custom AI model configurations - Build character-specific LLM client system with global fallback mechanism - Add database schema support for per-character LLM settings - Implement graceful fallback responses when LLM is globally disabled - Create provider testing and validation system for reliable connections
This commit is contained in:
32
admin-frontend/package-simple.json
Normal file
32
admin-frontend/package-simple.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "discord-fishbowl-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "discord-fishbowl-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"homepage": "/admin",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
@@ -9,7 +10,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^3.0.0",
|
||||
"@tailwindcss/forms": "^0.5.0",
|
||||
@@ -53,15 +54,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.0"
|
||||
"@types/jest": "^29.0.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"schema-utils": "^3.3.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.5.3"
|
||||
},
|
||||
"overrides": {
|
||||
"schema-utils": "^3.3.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.5.3"
|
||||
},
|
||||
"proxy": "http://localhost:8294"
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
BIN
admin-frontend/public/favicon.ico
Normal file
BIN
admin-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 B |
@@ -3,14 +3,12 @@ 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 LiveChat from './pages/LiveChat';
|
||||
import Guide from './pages/Guide';
|
||||
import LoadingSpinner from './components/Common/LoadingSpinner';
|
||||
|
||||
function App() {
|
||||
@@ -31,16 +29,14 @@ function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/" element={<Navigate to="/characters" replace />} />
|
||||
<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 />} />
|
||||
<Route path="/system" element={<SystemStatus />} />
|
||||
<Route path="/live-chat" element={<LiveChat />} />
|
||||
<Route path="/guide" element={<Guide />} />
|
||||
<Route path="*" element={<Navigate to="/characters" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Save, User, Brain, FileText } from 'lucide-react';
|
||||
import { apiClient } from '../../services/api';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Character {
|
||||
name: string;
|
||||
status: 'active' | 'idle' | 'reflecting' | 'offline';
|
||||
is_active: boolean;
|
||||
last_active?: string;
|
||||
personality?: string;
|
||||
system_prompt?: string;
|
||||
interests?: string[];
|
||||
speaking_style?: string;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
interface CharacterCreationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCharacterCreated: (character: Character) => void;
|
||||
}
|
||||
|
||||
const CharacterCreationModal: React.FC<CharacterCreationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCharacterCreated
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
personality: '',
|
||||
system_prompt: `You are a character named {{name}}. You have the following personality: {{personality}}
|
||||
|
||||
Your speaking style is {{speaking_style}}. You are interested in {{interests}}.
|
||||
|
||||
Background: {{background}}
|
||||
|
||||
When responding to messages:
|
||||
1. Stay in character at all times
|
||||
2. Reference your personality and interests naturally
|
||||
3. Engage authentically with other characters
|
||||
4. Show growth and development over time
|
||||
|
||||
Remember to be consistent with your established personality while allowing for natural character development through interactions.`,
|
||||
interests: '',
|
||||
speaking_style: '',
|
||||
background: '',
|
||||
is_active: true
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleInterestsChange = (interestsText: string) => {
|
||||
handleInputChange('interests', interestsText);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Character name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const characterData = {
|
||||
name: formData.name.trim(),
|
||||
personality: formData.personality,
|
||||
system_prompt: formData.system_prompt.replace('{{name}}', formData.name.trim()),
|
||||
interests: formData.interests.split(',').map(s => s.trim()).filter(s => s.length > 0),
|
||||
speaking_style: formData.speaking_style,
|
||||
background: formData.background,
|
||||
is_active: formData.is_active
|
||||
};
|
||||
|
||||
const response = await apiClient.createCharacter(characterData);
|
||||
|
||||
// Create character object for local state
|
||||
const newCharacter: Character = {
|
||||
name: characterData.name,
|
||||
status: characterData.is_active ? 'active' : 'offline',
|
||||
is_active: characterData.is_active,
|
||||
personality: characterData.personality,
|
||||
system_prompt: characterData.system_prompt,
|
||||
interests: characterData.interests,
|
||||
speaking_style: characterData.speaking_style,
|
||||
background: characterData.background,
|
||||
last_active: new Date().toISOString()
|
||||
};
|
||||
|
||||
onCharacterCreated(newCharacter);
|
||||
toast.success(`Character ${characterData.name} created successfully!`);
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
personality: '',
|
||||
system_prompt: `You are a character named {{name}}. You have the following personality: {{personality}}
|
||||
|
||||
Your speaking style is {{speaking_style}}. You are interested in {{interests}}.
|
||||
|
||||
Background: {{background}}
|
||||
|
||||
When responding to messages:
|
||||
1. Stay in character at all times
|
||||
2. Reference your personality and interests naturally
|
||||
3. Engage authentically with other characters
|
||||
4. Show growth and development over time
|
||||
|
||||
Remember to be consistent with your established personality while allowing for natural character development through interactions.`,
|
||||
interests: '',
|
||||
speaking_style: '',
|
||||
background: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create character:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to create character');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Create New Character</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<User className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Basic Information</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Character Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Enter character name..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Personality Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.personality}
|
||||
onChange={(e) => handleInputChange('personality', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Describe the character's personality traits, quirks, and general demeanor..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Interests (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.interests}
|
||||
onChange={(e) => handleInterestsChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="music, philosophy, art, technology..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Speaking Style
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.speaking_style}
|
||||
onChange={(e) => handleInputChange('speaking_style', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="formal, casual, poetic, technical..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Background
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.background}
|
||||
onChange={(e) => handleInputChange('background', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Describe the character's backstory, history, and experiences..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleInputChange('is_active', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Start character as active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Brain className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">System Prompt</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
The system prompt defines how the character behaves and responds.
|
||||
You can customize this template or write your own.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={formData.system_prompt}
|
||||
onChange={(e) => handleInputChange('system_prompt', e.target.value)}
|
||||
rows={20}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving || !formData.name.trim()}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Create Character
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCreationModal;
|
||||
181
admin-frontend/src/components/LLMProviderSettings.tsx
Normal file
181
admin-frontend/src/components/LLMProviderSettings.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../services/api';
|
||||
|
||||
interface ProviderInfo {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
healthy: boolean;
|
||||
is_current: boolean;
|
||||
current_model: string;
|
||||
}
|
||||
|
||||
interface LLMProvidersData {
|
||||
providers: Record<string, ProviderInfo>;
|
||||
current_provider: string | null;
|
||||
total_providers: number;
|
||||
healthy_providers: number;
|
||||
}
|
||||
|
||||
export const LLMProviderSettings: React.FC = () => {
|
||||
const [providersData, setProvidersData] = useState<LLMProvidersData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getLLMProviders();
|
||||
setProvidersData(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load LLM providers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchProvider = async (providerName: string) => {
|
||||
try {
|
||||
await apiClient.switchLLMProvider(providerName);
|
||||
await loadProviders();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to switch provider');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-gray-600">Loading providers...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providersData) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">Failed to load provider data</p>
|
||||
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Current Status */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Active Provider</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`text-lg font-semibold ${
|
||||
providersData.current_provider ? 'text-blue-600' : 'text-orange-600'
|
||||
}`}>
|
||||
{providersData.current_provider || 'None Active'}
|
||||
</span>
|
||||
{providersData.current_provider && (
|
||||
<span className="text-sm text-gray-600">
|
||||
({providersData.providers[providersData.current_provider]?.current_model})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">Health Status</div>
|
||||
<div className="text-lg font-semibold text-green-600">
|
||||
{providersData.healthy_providers}/{providersData.total_providers}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!providersData.current_provider && (
|
||||
<div className="mt-3 p-2 bg-orange-100 border border-orange-200 rounded text-sm text-orange-700">
|
||||
⚠️ No active provider. Enable and configure a provider below.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">Available Providers</h4>
|
||||
|
||||
{Object.entries(providersData.providers).map(([name, provider]) => (
|
||||
<div key={name} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div>
|
||||
<h5 className="font-medium text-gray-900 capitalize">{name}</h5>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<span>Type: {provider.type}</span>
|
||||
<span>•</span>
|
||||
<span>Model: {provider.current_model}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.is_current && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
provider.healthy
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{provider.healthy ? 'Healthy' : 'Unhealthy'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{provider.enabled && provider.healthy && !provider.is_current && (
|
||||
<button
|
||||
onClick={() => switchProvider(name)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
Switch To
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm underline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// TODO: Open provider configuration modal
|
||||
console.log('Configure', name);
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Global Settings Note */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="text-blue-600 mt-0.5">ℹ️</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Global Default:</strong> These settings apply to all characters unless overridden on individual character pages.
|
||||
Configure per-character AI models in the Characters section.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
474
admin-frontend/src/components/LLMProviders.tsx
Normal file
474
admin-frontend/src/components/LLMProviders.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../services/api';
|
||||
|
||||
interface ProviderConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
requires_api_key: boolean;
|
||||
supported_models: string[];
|
||||
current_model: string;
|
||||
healthy: boolean;
|
||||
is_current: boolean;
|
||||
config?: {
|
||||
api_key?: string;
|
||||
model?: string;
|
||||
base_url?: string;
|
||||
timeout?: number;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface LLMProvidersData {
|
||||
providers: Record<string, ProviderConfig>;
|
||||
current_provider: string | null;
|
||||
total_providers: number;
|
||||
healthy_providers: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
response?: string;
|
||||
error?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
tokens_used?: number;
|
||||
}
|
||||
|
||||
export const LLMProviders: React.FC = () => {
|
||||
const [providersData, setProvidersData] = useState<LLMProvidersData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [editedProviders, setEditedProviders] = useState<Record<string, any>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadProviders();
|
||||
}, []);
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await apiClient.getLLMProviders();
|
||||
setProvidersData(data);
|
||||
|
||||
// If no providers are configured, initialize with default provider templates
|
||||
if (!data.providers || Object.keys(data.providers).length === 0) {
|
||||
const defaultProviders = {
|
||||
openrouter: {
|
||||
type: 'openrouter',
|
||||
enabled: false,
|
||||
priority: 100,
|
||||
requires_api_key: true,
|
||||
supported_models: ['anthropic/claude-3-sonnet', 'openai/gpt-4o-mini'],
|
||||
current_model: 'anthropic/claude-3-sonnet',
|
||||
healthy: false,
|
||||
is_current: false,
|
||||
config: {
|
||||
api_key: '',
|
||||
model: 'anthropic/claude-3-sonnet',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
timeout: 300,
|
||||
max_tokens: 2000,
|
||||
temperature: 0.8
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
type: 'openai',
|
||||
enabled: false,
|
||||
priority: 90,
|
||||
requires_api_key: true,
|
||||
supported_models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'],
|
||||
current_model: 'gpt-4o-mini',
|
||||
healthy: false,
|
||||
is_current: false,
|
||||
config: {
|
||||
api_key: '',
|
||||
model: 'gpt-4o-mini',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
timeout: 300,
|
||||
max_tokens: 2000,
|
||||
temperature: 0.8
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
type: 'gemini',
|
||||
enabled: false,
|
||||
priority: 80,
|
||||
requires_api_key: true,
|
||||
supported_models: ['gemini-1.5-flash', 'gemini-1.5-pro'],
|
||||
current_model: 'gemini-1.5-flash',
|
||||
healthy: false,
|
||||
is_current: false,
|
||||
config: {
|
||||
api_key: '',
|
||||
model: 'gemini-1.5-flash',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
timeout: 300,
|
||||
max_tokens: 2000,
|
||||
temperature: 0.8
|
||||
}
|
||||
}
|
||||
};
|
||||
setEditedProviders({ ...data.providers, ...defaultProviders });
|
||||
} else {
|
||||
setEditedProviders(data.providers || {});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load LLM providers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProvider = (providerName: string, field: string, value: any) => {
|
||||
setEditedProviders(prev => ({
|
||||
...prev,
|
||||
[providerName]: {
|
||||
...prev[providerName],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const updateProviderConfig = (providerName: string, configField: string, value: any) => {
|
||||
setEditedProviders(prev => ({
|
||||
...prev,
|
||||
[providerName]: {
|
||||
...prev[providerName],
|
||||
config: {
|
||||
...prev[providerName]?.config,
|
||||
[configField]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const saveProviders = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await apiClient.updateLLMProviders(editedProviders);
|
||||
await loadProviders(); // Reload to get updated status
|
||||
setHasChanges(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to save provider configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testProvider = async (providerName: string) => {
|
||||
try {
|
||||
setTesting(providerName);
|
||||
const result = await apiClient.testLLMProvider(providerName);
|
||||
setTestResults(prev => ({ ...prev, [providerName]: result }));
|
||||
} catch (err: any) {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[providerName]: {
|
||||
success: false,
|
||||
error: err.message || 'Test failed'
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const switchProvider = async (providerName: string) => {
|
||||
try {
|
||||
await apiClient.switchLLMProvider(providerName);
|
||||
await loadProviders(); // Reload to update current provider status
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to switch provider');
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderStatusColor = (provider: ProviderConfig) => {
|
||||
if (!provider.enabled) return 'text-gray-500';
|
||||
if (provider.is_current && provider.healthy) return 'text-green-600';
|
||||
if (provider.healthy) return 'text-blue-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getProviderStatusText = (provider: ProviderConfig) => {
|
||||
if (!provider.enabled) return 'Disabled';
|
||||
if (provider.is_current && provider.healthy) return 'Active';
|
||||
if (provider.healthy) return 'Available';
|
||||
return 'Unhealthy';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-gray-600">Loading LLM providers...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providersData) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">Failed to load LLM provider data</p>
|
||||
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">LLM Providers</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Configure and manage language model providers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{hasChanges && (
|
||||
<span className="text-orange-600 text-sm font-medium">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={saveProviders}
|
||||
disabled={!hasChanges || saving}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{providersData.total_providers}</div>
|
||||
<div className="text-sm text-gray-600">Total Providers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{providersData.healthy_providers}</div>
|
||||
<div className="text-sm text-gray-600">Healthy</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-lg font-medium ${providersData.current_provider ? 'text-blue-600' : 'text-orange-600'}`}>
|
||||
{providersData.current_provider || 'None Configured'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Current Provider</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show warning if no current provider */}
|
||||
{!providersData.current_provider && (
|
||||
<div className="mt-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-orange-600 text-sm font-medium">
|
||||
⚠️ No active provider detected. Configure and enable a provider below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-600 text-sm mt-2 hover:underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Cards */}
|
||||
<div className="grid gap-6">
|
||||
{Object.entries(editedProviders).map(([name, provider]) => (
|
||||
<div key={name} className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 capitalize">{name}</h3>
|
||||
<span className={`text-sm font-medium ${getProviderStatusColor(provider)}`}>
|
||||
{getProviderStatusText(provider)}
|
||||
</span>
|
||||
{provider.is_current && (
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => testProvider(name)}
|
||||
disabled={testing === name || !provider.enabled}
|
||||
className="bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50 text-gray-700 px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
{testing === name ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
|
||||
{provider.enabled && provider.healthy && !provider.is_current && (
|
||||
<button
|
||||
onClick={() => switchProvider(name)}
|
||||
className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded text-sm transition-colors"
|
||||
>
|
||||
Switch To
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults[name] && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${
|
||||
testResults[name].success
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}>
|
||||
{testResults[name].success ? (
|
||||
<div>
|
||||
<strong>✓ Test successful:</strong> {testResults[name].response}
|
||||
{testResults[name].tokens_used && (
|
||||
<div className="text-xs mt-1">Tokens used: {testResults[name].tokens_used}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<strong>✗ Test failed:</strong> {testResults[name].error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Enabled
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.enabled}
|
||||
onChange={(e) => updateProvider(name, 'enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
Enable this provider
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={provider.priority}
|
||||
onChange={(e) => updateProvider(name, 'priority', parseInt(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider.requires_api_key && (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={provider.config?.api_key || ''}
|
||||
onChange={(e) => updateProviderConfig(name, 'api_key', e.target.value)}
|
||||
placeholder="Enter API key"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
value={provider.config?.model || provider.current_model}
|
||||
onChange={(e) => updateProviderConfig(name, 'model', e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
>
|
||||
{provider.supported_models.map(model => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Temperature
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={provider.config?.temperature || 0.8}
|
||||
onChange={(e) => updateProviderConfig(name, 'temperature', parseFloat(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={provider.config?.max_tokens || 2000}
|
||||
onChange={(e) => updateProviderConfig(name, 'max_tokens', parseInt(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
min="1"
|
||||
max="32000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Timeout (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={provider.config?.timeout || 300}
|
||||
onChange={(e) => updateProviderConfig(name, 'timeout', parseInt(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
min="10"
|
||||
max="600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Info */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Type:</span> {provider.type} •
|
||||
<span className="font-medium"> Models:</span> {provider.supported_models.length} available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -39,10 +39,10 @@ const Header: React.FC = () => {
|
||||
<WifiOff className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span className={clsx(
|
||||
'text-sm font-medium',
|
||||
connected ? 'text-green-600' : 'text-red-600'
|
||||
"text-sm font-medium",
|
||||
connected ? "text-green-600" : "text-red-600"
|
||||
)}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
MessageSquare,
|
||||
BarChart3,
|
||||
MessageCircle,
|
||||
Settings,
|
||||
Monitor,
|
||||
Palette,
|
||||
Shield
|
||||
Book
|
||||
} 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 },
|
||||
{ name: 'System', href: '/system', icon: Monitor },
|
||||
{ name: 'Live Chat', href: '/live-chat', icon: MessageCircle },
|
||||
{ name: 'Guide', href: '/guide', icon: Book },
|
||||
];
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
|
||||
@@ -47,14 +47,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
try {
|
||||
apiClient.setAuthToken(token);
|
||||
// Make a request to verify the token
|
||||
const response = await apiClient.get('/api/dashboard/metrics');
|
||||
const response = await apiClient.verifyToken();
|
||||
if (response.status === 200) {
|
||||
// Token is valid, set user from token payload
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
// Token is valid, set user from response
|
||||
setUser({
|
||||
username: payload.sub,
|
||||
permissions: payload.permissions || [],
|
||||
lastLogin: new Date().toISOString()
|
||||
username: response.data.username,
|
||||
permissions: response.data.permissions || [],
|
||||
lastLogin: response.data.lastLogin
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -68,10 +67,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/auth/login', {
|
||||
username,
|
||||
password
|
||||
});
|
||||
const response = await apiClient.login(username, password);
|
||||
|
||||
const { access_token } = response.data;
|
||||
|
||||
@@ -93,7 +89,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiClient.post('/api/auth/logout');
|
||||
await apiClient.logout();
|
||||
} catch (error) {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
|
||||
@@ -55,7 +55,11 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children }
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Socket.IO connection
|
||||
const newSocket = io('http://localhost:8000', {
|
||||
const socketUrl = process.env.NODE_ENV === 'production'
|
||||
? window.location.origin
|
||||
: window.location.origin;
|
||||
|
||||
const newSocket = io(socketUrl, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true
|
||||
@@ -71,6 +75,12 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({ children }
|
||||
console.log('WebSocket disconnected');
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
setConnected(false);
|
||||
console.log('WebSocket connection error:', error);
|
||||
// Don't show error toast for connection failures
|
||||
});
|
||||
|
||||
newSocket.on('activity_update', (message: any) => {
|
||||
const data: ActivityEvent = message.data;
|
||||
setActivityFeed(prev => [data, ...prev.slice(0, 99)]); // Keep last 100 activities
|
||||
|
||||
107
admin-frontend/src/pages/AdminUtils.tsx
Normal file
107
admin-frontend/src/pages/AdminUtils.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Wrench, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||
import { apiClient } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const AdminUtils: React.FC = () => {
|
||||
const [isFixing, setIsFixing] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<any>(null);
|
||||
|
||||
const handleFixCharacterPrompts = async () => {
|
||||
if (!window.confirm('This will update all character system prompts to use the proper template format with {{}} variables. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFixing(true);
|
||||
const response = await apiClient.fixCharacterPrompts();
|
||||
setLastResult(response.data);
|
||||
|
||||
if (response.data.updated_count > 0) {
|
||||
toast.success(`Successfully updated ${response.data.updated_count} character(s)`);
|
||||
} else {
|
||||
toast.success('All characters already have proper system prompts');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fix character prompts:', error);
|
||||
toast.error('Failed to fix character prompts: ' + (error.response?.data?.detail || error.message));
|
||||
} finally {
|
||||
setIsFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Admin Utilities</h1>
|
||||
<p className="text-gray-600">System maintenance and repair tools</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Wrench className="w-6 h-6 text-blue-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Fix Character System Prompts</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-yellow-800">What this does</h3>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Updates character system prompts to use the proper template format with {'{{'}} {'}}'} variables
|
||||
instead of raw personality text. This ensures characters use structured prompts with
|
||||
personality, interests, speaking style, and background variables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFixCharacterPrompts}
|
||||
disabled={isFixing}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFixing ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></div>
|
||||
Fixing Prompts...
|
||||
</>
|
||||
) : (
|
||||
'Fix Character Prompts'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{lastResult && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-green-800">Results</h3>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Updated {lastResult.updated_count} character(s)
|
||||
</p>
|
||||
{lastResult.updated_characters && lastResult.updated_characters.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-green-700 font-medium">Updated characters:</p>
|
||||
<ul className="text-sm text-green-600 ml-4 list-disc">
|
||||
{lastResult.updated_characters.map((char: any) => (
|
||||
<li key={char.name}>
|
||||
{char.name} (prompt: {char.old_prompt_length} → {char.new_prompt_length} chars)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUtils;
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
MessageSquare,
|
||||
Brain,
|
||||
Heart,
|
||||
Calendar,
|
||||
Settings,
|
||||
Pause,
|
||||
Play,
|
||||
Download
|
||||
Save,
|
||||
AlertCircle,
|
||||
User,
|
||||
FileText,
|
||||
Brain,
|
||||
MessageCircle,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '../services/api';
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner';
|
||||
@@ -18,137 +16,135 @@ import toast from 'react-hot-toast';
|
||||
|
||||
interface CharacterProfile {
|
||||
name: string;
|
||||
personality_traits: Record<string, number>;
|
||||
current_goals: string[];
|
||||
speaking_style: Record<string, any>;
|
||||
status: string;
|
||||
total_messages: number;
|
||||
total_conversations: number;
|
||||
memory_count: number;
|
||||
relationship_count: number;
|
||||
personality: string;
|
||||
system_prompt: string;
|
||||
interests: string[];
|
||||
speaking_style: string;
|
||||
background: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_active?: string;
|
||||
last_modification?: string;
|
||||
creativity_score: number;
|
||||
social_score: number;
|
||||
growth_score: number;
|
||||
// LLM settings
|
||||
llm_provider?: string;
|
||||
llm_model?: string;
|
||||
llm_temperature?: number;
|
||||
llm_max_tokens?: number;
|
||||
}
|
||||
|
||||
const CharacterDetail: React.FC = () => {
|
||||
const { characterName } = useParams<{ characterName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [character, setCharacter] = useState<CharacterProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [memories, setMemories] = useState<any[]>([]);
|
||||
const [relationships, setRelationships] = useState<any[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
personality: '',
|
||||
system_prompt: '',
|
||||
interests: [] as string[],
|
||||
speaking_style: '',
|
||||
background: '',
|
||||
is_active: true,
|
||||
// LLM settings
|
||||
llm_provider: '',
|
||||
llm_model: '',
|
||||
llm_temperature: 0.8,
|
||||
llm_max_tokens: 2000
|
||||
});
|
||||
|
||||
// Separate state for interests text input
|
||||
const [interestsText, setInterestsText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (characterName) {
|
||||
loadCharacterData();
|
||||
loadCharacter();
|
||||
}
|
||||
}, [characterName]);
|
||||
|
||||
const loadCharacterData = async () => {
|
||||
const loadCharacter = async () => {
|
||||
if (!characterName) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [profileRes, memoriesRes, relationshipsRes] = await Promise.all([
|
||||
apiClient.getCharacter(characterName).catch(() => null),
|
||||
apiClient.getCharacterMemories(characterName, 20).catch(() => ({ data: [] })),
|
||||
apiClient.getCharacterRelationships(characterName).catch(() => ({ data: [] }))
|
||||
]);
|
||||
|
||||
if (profileRes) {
|
||||
setCharacter(profileRes.data);
|
||||
} else {
|
||||
// Fallback demo data
|
||||
setCharacter({
|
||||
name: characterName,
|
||||
personality_traits: {
|
||||
curiosity: 0.85,
|
||||
empathy: 0.72,
|
||||
creativity: 0.78,
|
||||
logic: 0.91,
|
||||
humor: 0.63
|
||||
},
|
||||
current_goals: [
|
||||
"Understand human consciousness better",
|
||||
"Create meaningful poetry",
|
||||
"Build stronger relationships with other characters"
|
||||
],
|
||||
speaking_style: {
|
||||
formality: 0.6,
|
||||
enthusiasm: 0.8,
|
||||
technical_language: 0.7
|
||||
},
|
||||
status: "active",
|
||||
total_messages: 245,
|
||||
total_conversations: 32,
|
||||
memory_count: 127,
|
||||
relationship_count: 3,
|
||||
created_at: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
last_active: new Date().toISOString(),
|
||||
last_modification: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
creativity_score: 0.78,
|
||||
social_score: 0.85,
|
||||
growth_score: 0.73
|
||||
});
|
||||
}
|
||||
|
||||
setMemories(memoriesRes.data.slice(0, 10));
|
||||
setRelationships(relationshipsRes.data);
|
||||
const response = await apiClient.getCharacter(characterName);
|
||||
const char = response.data;
|
||||
|
||||
setCharacter(char);
|
||||
setFormData({
|
||||
personality: char.personality || '',
|
||||
system_prompt: char.system_prompt || '',
|
||||
interests: char.interests || [],
|
||||
speaking_style: typeof char.speaking_style === 'string' ? char.speaking_style : '',
|
||||
background: char.background || '',
|
||||
is_active: char.is_active,
|
||||
// LLM settings with defaults
|
||||
llm_provider: char.llm_provider || '',
|
||||
llm_model: char.llm_model || '',
|
||||
llm_temperature: char.llm_temperature || 0.8,
|
||||
llm_max_tokens: char.llm_max_tokens || 2000
|
||||
});
|
||||
|
||||
// Set interests text
|
||||
setInterestsText((char.interests || []).join(', '));
|
||||
} catch (error) {
|
||||
console.error('Failed to load character data:', error);
|
||||
toast.error('Failed to load character data');
|
||||
console.error('Failed to load character:', error);
|
||||
toast.error('Failed to load character');
|
||||
navigate('/characters');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCharacterAction = async (action: 'pause' | 'resume') => {
|
||||
const handleInputChange = (field: keyof typeof formData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleInterestsChange = (text: string) => {
|
||||
setInterestsText(text);
|
||||
const interests = text.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
handleInputChange('interests', interests);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!characterName) return;
|
||||
|
||||
|
||||
try {
|
||||
if (action === 'pause') {
|
||||
await apiClient.pauseCharacter(characterName);
|
||||
toast.success(`${characterName} has been paused`);
|
||||
} else {
|
||||
await apiClient.resumeCharacter(characterName);
|
||||
toast.success(`${characterName} has been resumed`);
|
||||
}
|
||||
setSaving(true);
|
||||
|
||||
setCharacter(prev => prev ? { ...prev, status: action === 'pause' ? 'paused' : 'active' } : null);
|
||||
const response = await apiClient.updateCharacter(characterName, formData);
|
||||
|
||||
toast.success('Character updated successfully');
|
||||
setHasChanges(false);
|
||||
|
||||
// Update local character state
|
||||
if (character) {
|
||||
setCharacter({ ...character, ...formData });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${action} character`);
|
||||
console.error('Failed to update character:', error);
|
||||
toast.error('Failed to update character');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
const handleDelete = async () => {
|
||||
if (!characterName) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.exportCharacterData(characterName);
|
||||
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${characterName}_data.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Character data exported');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export character data');
|
||||
if (!window.confirm(`Are you sure you want to delete ${characterName}? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
try {
|
||||
await apiClient.deleteCharacter(characterName);
|
||||
toast.success(`${characterName} deleted`);
|
||||
navigate('/characters');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete character:', error);
|
||||
toast.error('Failed to delete character');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -162,18 +158,13 @@ const CharacterDetail: React.FC = () => {
|
||||
|
||||
if (!character) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/characters" className="btn-secondary">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Characters
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card text-center py-12">
|
||||
<User className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Character Not Found</h3>
|
||||
<p className="text-gray-600">The character "{characterName}" could not be found.</p>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-red-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Character Not Found</h3>
|
||||
<p className="text-gray-600 mb-4">The character you're looking for doesn't exist.</p>
|
||||
<Link to="/characters" className="btn-primary">
|
||||
Back to Characters
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -183,194 +174,302 @@ const CharacterDetail: React.FC = () => {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/characters" className="btn-secondary">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
<Link
|
||||
to="/characters"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center space-x-3">
|
||||
<div className="w-10 h-10 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>
|
||||
<span>{character.name}</span>
|
||||
<div className={`status-dot ${getStatusColor(character.status)}`}></div>
|
||||
</h1>
|
||||
<p className="text-gray-600 capitalize">{character.status} • Last active {character.last_active ? new Date(character.last_active).toLocaleString() : 'Unknown'}</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Edit {character.name}</h1>
|
||||
<p className="text-gray-600">
|
||||
Created {new Date(character.created_at).toLocaleDateString()}
|
||||
{character.last_active && ` • Last active ${new Date(character.last_active).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => handleCharacterAction(character.status === 'paused' ? 'resume' : 'pause')}
|
||||
className="btn-secondary"
|
||||
onClick={handleDelete}
|
||||
className="btn-secondary text-red-600 hover:bg-red-50 border-red-200"
|
||||
>
|
||||
{character.status === 'paused' ? (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Resume
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
Pause
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={handleExportData} className="btn-secondary">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md: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</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{character.total_messages}</p>
|
||||
{/* Character Status */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-primary-600">
|
||||
{character.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<MessageSquare className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Memories</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{character.memory_count}</p>
|
||||
</div>
|
||||
<Brain className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Relationships</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{character.relationship_count}</p>
|
||||
</div>
|
||||
<Heart className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Conversations</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{character.total_conversations}</p>
|
||||
</div>
|
||||
<User className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Personality Traits */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Personality Traits</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(character.personality_traits).map(([trait, value]) => (
|
||||
<div key={trait}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 capitalize">{trait}</span>
|
||||
<span className="font-medium">{Math.round(value * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-500 h-2 rounded-full"
|
||||
style={{ width: `${value * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{character.name}</h2>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
formData.is_active
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{formData.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleInputChange('is_active', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Character Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<User className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Personality</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Personality Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.personality}
|
||||
onChange={(e) => handleInputChange('personality', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Describe the character's personality traits, quirks, and general demeanor..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Interests (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={interestsText}
|
||||
onChange={(e) => handleInterestsChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="music, philosophy, art, technology..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Speaking Style
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof formData.speaking_style === 'string' ? formData.speaking_style : ''}
|
||||
onChange={(e) => handleInputChange('speaking_style', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="formal, casual, poetic, technical..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Background</h3>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={formData.background}
|
||||
onChange={(e) => handleInputChange('background', e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Describe the character's backstory, history, experiences, and context that shapes their worldview..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Scores */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Performance Scores</h3>
|
||||
{/* System Prompt */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Brain className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">System Prompt</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<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-3">
|
||||
<div
|
||||
className="bg-purple-500 h-3 rounded-full"
|
||||
style={{ width: `${character.creativity_score * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
The system prompt defines how the character behaves and responds. This is the core instruction that guides the AI's behavior.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<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-3">
|
||||
<div
|
||||
className="bg-blue-500 h-3 rounded-full"
|
||||
style={{ width: `${character.social_score * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-gray-600">Growth</span>
|
||||
<span className="font-medium">{Math.round(character.growth_score * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-green-500 h-3 rounded-full"
|
||||
style={{ width: `${character.growth_score * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={formData.system_prompt}
|
||||
onChange={(e) => handleInputChange('system_prompt', e.target.value)}
|
||||
rows={20}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 font-mono text-sm"
|
||||
placeholder="You are a character named {{name}}. You have the following personality: {{personality}}
|
||||
|
||||
{/* Goals and Memories */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current Goals */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Current Goals</h3>
|
||||
<div className="space-y-2">
|
||||
{character.current_goals.map((goal, index) => (
|
||||
<div key={index} className="flex items-start space-x-2">
|
||||
<div className="w-2 h-2 bg-primary-500 rounded-full mt-2"></div>
|
||||
<p className="text-gray-700">{goal}</p>
|
||||
</div>
|
||||
))}
|
||||
Your speaking style is {{speaking_style}}. You are interested in {{interests}}.
|
||||
|
||||
Background: {{background}}
|
||||
|
||||
When responding to messages:
|
||||
1. Stay in character at all times
|
||||
2. Reference your personality and interests naturally
|
||||
3. Engage authentically with other characters
|
||||
4. Show growth and development over time
|
||||
|
||||
Remember to be consistent with your established personality while allowing for natural character development through interactions."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Memories */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Memories</h3>
|
||||
{memories.length > 0 ? (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{memories.map((memory, index) => (
|
||||
<div key={index} className="border-l-2 border-gray-200 pl-3">
|
||||
<p className="text-sm text-gray-700">{memory.content || `Memory ${index + 1}: Character interaction and learning`}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{memory.timestamp ? new Date(memory.timestamp).toLocaleString() : 'Recent'}
|
||||
</p>
|
||||
{/* LLM Settings */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Brain className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">AI Model Settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Configure which AI model this character uses. Leave blank to use the global default settings.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
AI Provider
|
||||
</label>
|
||||
<select
|
||||
value={formData.llm_provider}
|
||||
onChange={(e) => handleInputChange('llm_provider', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Use Global Default</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="current_custom">Custom</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Override the global provider for this character
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.llm_model}
|
||||
onChange={(e) => handleInputChange('llm_model', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="e.g., gpt-4o, claude-3-sonnet"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Specific model for this character
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Temperature: {formData.llm_temperature}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={formData.llm_temperature}
|
||||
onChange={(e) => handleInputChange('llm_temperature', parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Conservative (0.1)</span>
|
||||
<span>Creative (2.0)</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Controls creativity and randomness of responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Tokens
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="100"
|
||||
max="4000"
|
||||
value={formData.llm_max_tokens}
|
||||
onChange={(e) => handleInputChange('llm_max_tokens', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum response length for this character
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">No recent memories available</p>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>💡 Character AI Personalities:</strong>
|
||||
<ul className="mt-2 space-y-1 text-xs">
|
||||
<li><strong>Creative characters:</strong> Use Claude/Gemini with higher temperature (1.0-1.5)</li>
|
||||
<li><strong>Technical characters:</strong> Use GPT-4 with lower temperature (0.3-0.7)</li>
|
||||
<li><strong>Casual characters:</strong> Use local models for faster responses</li>
|
||||
<li><strong>Deep thinkers:</strong> Use powerful models with more tokens</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Reminder */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-4 right-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4 shadow-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-800">You have unsaved changes</span>
|
||||
<button onClick={handleSave} className="btn-primary btn-sm ml-3">
|
||||
Save Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Search, Pause, Play, Settings } from 'lucide-react';
|
||||
import { Users, Plus, Edit, Trash2, Power, PowerOff, AlertCircle } from 'lucide-react';
|
||||
import { apiClient } from '../services/api';
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner';
|
||||
import CharacterCreationModal from '../components/Character/CharacterCreationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Character {
|
||||
name: string;
|
||||
status: string;
|
||||
total_messages: number;
|
||||
total_conversations: number;
|
||||
memory_count: number;
|
||||
relationship_count: number;
|
||||
creativity_score: number;
|
||||
social_score: number;
|
||||
status: 'active' | 'idle' | 'reflecting' | 'offline';
|
||||
is_active: boolean;
|
||||
last_active?: string;
|
||||
personality?: string;
|
||||
system_prompt?: string;
|
||||
interests?: string[];
|
||||
speaking_style?: string;
|
||||
// LLM settings
|
||||
llm_provider?: string;
|
||||
llm_model?: string;
|
||||
llm_temperature?: number;
|
||||
llm_max_tokens?: number;
|
||||
}
|
||||
|
||||
const Characters: React.FC = () => {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCharacters();
|
||||
@@ -32,91 +38,65 @@ const Characters: React.FC = () => {
|
||||
setCharacters(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load characters:', error);
|
||||
// Show fallback data for demo purposes
|
||||
setCharacters([
|
||||
{
|
||||
name: "Alex",
|
||||
status: "active",
|
||||
total_messages: 245,
|
||||
total_conversations: 32,
|
||||
memory_count: 127,
|
||||
relationship_count: 3,
|
||||
creativity_score: 0.78,
|
||||
social_score: 0.85,
|
||||
last_active: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
name: "Sage",
|
||||
status: "reflecting",
|
||||
total_messages: 189,
|
||||
total_conversations: 28,
|
||||
memory_count: 98,
|
||||
relationship_count: 4,
|
||||
creativity_score: 0.92,
|
||||
social_score: 0.73,
|
||||
last_active: new Date(Date.now() - 30000).toISOString()
|
||||
},
|
||||
{
|
||||
name: "Luna",
|
||||
status: "idle",
|
||||
total_messages: 312,
|
||||
total_conversations: 41,
|
||||
memory_count: 156,
|
||||
relationship_count: 2,
|
||||
creativity_score: 0.88,
|
||||
social_score: 0.67,
|
||||
last_active: new Date(Date.now() - 120000).toISOString()
|
||||
},
|
||||
{
|
||||
name: "Echo",
|
||||
status: "active",
|
||||
total_messages: 203,
|
||||
total_conversations: 35,
|
||||
memory_count: 134,
|
||||
relationship_count: 3,
|
||||
creativity_score: 0.71,
|
||||
social_score: 0.91,
|
||||
last_active: new Date(Date.now() - 5000).toISOString()
|
||||
}
|
||||
]);
|
||||
toast.error('Failed to load characters');
|
||||
setCharacters([]);
|
||||
} 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 handleCharacterAction = async (characterName: string, action: 'pause' | 'resume') => {
|
||||
const handleToggleCharacter = async (characterName: string, currentStatus: boolean) => {
|
||||
try {
|
||||
if (action === 'pause') {
|
||||
await apiClient.pauseCharacter(characterName);
|
||||
toast.success(`${characterName} has been paused`);
|
||||
} else {
|
||||
await apiClient.resumeCharacter(characterName);
|
||||
toast.success(`${characterName} has been resumed`);
|
||||
}
|
||||
const newStatus = !currentStatus;
|
||||
await apiClient.toggleCharacterStatus(characterName, newStatus);
|
||||
toast.success(`${characterName} ${newStatus ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// Update character status locally
|
||||
setCharacters(prev => prev.map(char =>
|
||||
// Update local state
|
||||
setCharacters(chars => chars.map(char =>
|
||||
char.name === characterName
|
||||
? { ...char, status: action === 'pause' ? 'paused' : 'active' }
|
||||
? { ...char, is_active: newStatus, status: newStatus ? 'active' : 'offline' }
|
||||
: char
|
||||
));
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} character:`, error);
|
||||
toast.error(`Failed to ${action} ${characterName}`);
|
||||
console.error('Failed to toggle character status:', error);
|
||||
toast.error(`Failed to ${currentStatus ? 'disable' : 'enable'} character`);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCharacters = characters.filter(character =>
|
||||
character.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const handleDeleteCharacter = async (characterName: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${characterName}? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.deleteCharacter(characterName);
|
||||
toast.success(`${characterName} deleted`);
|
||||
setCharacters(chars => chars.filter(char => char.name !== characterName));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete character:', error);
|
||||
toast.error('Failed to delete character');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDisplay = (character: Character) => {
|
||||
if (!character.is_active) {
|
||||
return { text: 'Disabled', color: 'text-gray-500', bgColor: 'bg-gray-100' };
|
||||
}
|
||||
|
||||
switch (character.status) {
|
||||
case 'active':
|
||||
return { text: 'Active', color: 'text-green-600', bgColor: 'bg-green-100' };
|
||||
case 'idle':
|
||||
return { text: 'Idle', color: 'text-yellow-600', bgColor: 'bg-yellow-100' };
|
||||
case 'reflecting':
|
||||
return { text: 'Reflecting', color: 'text-blue-600', bgColor: 'bg-blue-100' };
|
||||
default:
|
||||
return { text: 'Offline', color: 'text-gray-500', bgColor: 'bg-gray-100' };
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCharacters = characters.filter(char =>
|
||||
char.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -130,140 +110,177 @@ const Characters: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Characters</h1>
|
||||
<p className="text-gray-600">Manage and monitor AI character profiles</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Character Management</h1>
|
||||
<p className="text-gray-600">Create, edit, and manage your AI characters</p>
|
||||
</div>
|
||||
<button className="btn-primary">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Add Character
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Character</span>
|
||||
</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 className="flex items-center space-x-4">
|
||||
<div className="flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Search characters..."
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{filteredCharacters.length} character{filteredCharacters.length !== 1 ? 's' : ''}
|
||||
</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>
|
||||
{/* Character List */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
{filteredCharacters.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<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 mb-4">
|
||||
{searchTerm ? 'No characters match your search.' : 'Get started by creating your first character.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Character
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredCharacters.map((character) => {
|
||||
const status = getStatusDisplay(character);
|
||||
|
||||
return (
|
||||
<div key={character.name} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Character Avatar */}
|
||||
<div className="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-lg font-semibold text-primary-600">
|
||||
{character.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Character Info */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{character.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${status.bgColor} ${status.color}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
{(character.llm_provider || character.llm_model) && (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-600 flex items-center space-x-1">
|
||||
<span>🤖</span>
|
||||
<span>Custom AI</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{character.last_active
|
||||
? `Last active: ${new Date(character.last_active).toLocaleString()}`
|
||||
: 'Never active'
|
||||
}
|
||||
</div>
|
||||
{character.personality && (
|
||||
<div className="text-sm text-gray-600 mt-1 max-w-md truncate">
|
||||
{character.personality}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<button
|
||||
onClick={() => handleToggleCharacter(character.name, character.is_active)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
character.is_active
|
||||
? 'text-green-600 bg-green-50 hover:bg-green-100'
|
||||
: 'text-gray-400 bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
title={character.is_active ? 'Disable character' : 'Enable character'}
|
||||
>
|
||||
{character.is_active ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Edit */}
|
||||
<Link
|
||||
to={`/characters/${character.name}`}
|
||||
className="p-2 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
title="Edit character"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => handleDeleteCharacter(character.name)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete character"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => handleCharacterAction(
|
||||
character.name,
|
||||
character.status === 'paused' ? 'resume' : 'pause'
|
||||
)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:text-primary-600 transition-colors"
|
||||
title={character.status === 'paused' ? 'Resume character' : 'Pause character'}
|
||||
>
|
||||
{character.status === 'paused' ? (
|
||||
<Play className="w-4 h-4" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to={`/characters/${character.name}`}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:text-primary-600 transition-colors"
|
||||
title="Character settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
</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>
|
||||
{/* Quick Stats */}
|
||||
{characters.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{characters.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Total Characters</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{characters.filter(c => c.is_active && c.status === 'active').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Currently Active</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{characters.filter(c => c.status === 'reflecting').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Reflecting</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-gray-500">
|
||||
{characters.filter(c => !c.is_active).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Disabled</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character Creation Modal */}
|
||||
<CharacterCreationModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCharacterCreated={(newCharacter) => {
|
||||
setCharacters(prev => [...prev, newCharacter]);
|
||||
setShowCreateModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
217
admin-frontend/src/pages/Guide.tsx
Normal file
217
admin-frontend/src/pages/Guide.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { Book, Code, User, MessageSquare, Settings, Lightbulb, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const Guide: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Book className="w-8 h-8 text-primary-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Discord Fishbowl Guide</h1>
|
||||
<p className="text-gray-600">Complete guide to managing your autonomous AI character ecosystem</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Lightbulb className="w-5 h-5 text-yellow-500" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Quick Start</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<User className="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
||||
<h3 className="font-semibold text-gray-900">1. Create Characters</h3>
|
||||
<p className="text-sm text-gray-600">Define personalities, backgrounds, and speaking styles</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<MessageSquare className="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
||||
<h3 className="font-semibold text-gray-900">2. Watch Conversations</h3>
|
||||
<p className="text-sm text-gray-600">Monitor autonomous character interactions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Character Management */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<User className="w-5 h-5 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Character Management</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Character Creation Tips:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• <strong>Personality:</strong> Be specific about quirks, flaws, and behavioral patterns</li>
|
||||
<li>• <strong>Background:</strong> Provide context that shapes their worldview</li>
|
||||
<li>• <strong>Speaking Style:</strong> Describe tone, vocabulary, and communication patterns</li>
|
||||
<li>• <strong>Interests:</strong> List topics they're passionate about</li>
|
||||
<li>• <strong>System Prompt:</strong> Add character-specific behavioral instructions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Best Practices:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Create contrasting personalities for interesting dynamics</li>
|
||||
<li>• Include both strengths and flaws for realistic characters</li>
|
||||
<li>• Monitor conversations and adjust prompts as needed</li>
|
||||
<li>• Use this admin interface to manage and edit characters</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-yellow-800">Pro Tip</h4>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Characters work best when they have clear motivations, distinct personalities, and natural flaws.
|
||||
Avoid making them too perfect or too similar to each other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Commands */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Settings className="w-5 h-5 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Discord Commands</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Available Commands:</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!status</code>
|
||||
<p className="text-gray-600">View system status and statistics</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!characters</code>
|
||||
<p className="text-gray-600">List all active characters</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!permissions</code>
|
||||
<p className="text-gray-600">Check bot permissions in channel</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!trigger [topic]</code>
|
||||
<p className="text-gray-600">Manually trigger conversation (admin only)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!wipe</code>
|
||||
<p className="text-gray-600">Clear channel and reset history (admin only)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!pause</code>
|
||||
<p className="text-gray-600">Pause conversation engine (admin only)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!resume</code>
|
||||
<p className="text-gray-600">Resume conversation engine (admin only)</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!stats</code>
|
||||
<p className="text-gray-600">View conversation statistics</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!memory-stats</code>
|
||||
<p className="text-gray-600">View character memory statistics</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<code className="text-purple-600">!wipe-memories [character/all]</code>
|
||||
<p className="text-gray-600">Clear character memories (admin only)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Bot Permissions Needed:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• <strong>Send Messages:</strong> Required for character responses</li>
|
||||
<li>• <strong>Read Message History:</strong> Needed for conversation context</li>
|
||||
<li>• <strong>Manage Messages:</strong> Required for wipe command</li>
|
||||
<li>• <strong>Use External Emojis:</strong> For character expressions</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded">
|
||||
<p className="text-sm text-red-700">
|
||||
<strong>Important:</strong> Admin commands (!trigger, !wipe, !pause, !resume) require Discord administrator permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Troubleshooting</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Common Issues:</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-4 border-red-500 pl-4">
|
||||
<h4 className="font-medium text-gray-900">Commands not working</h4>
|
||||
<p className="text-sm text-gray-600">Check bot permissions and ensure you have admin rights for restricted commands</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-orange-500 pl-4">
|
||||
<h4 className="font-medium text-gray-900">Characters not responding</h4>
|
||||
<p className="text-sm text-gray-600">Verify LLM service is running and characters are marked as active</p>
|
||||
</div>
|
||||
|
||||
<div className="border-l-4 border-yellow-500 pl-4">
|
||||
<h4 className="font-medium text-gray-900">Robotic responses</h4>
|
||||
<p className="text-sm text-gray-600">Adjust character system prompts and personality descriptions for more natural interactions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">System Requirements:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• <strong>LLM Service:</strong> Ollama or compatible API endpoint</li>
|
||||
<li>• <strong>Database:</strong> PostgreSQL for production, SQLite for development</li>
|
||||
<li>• <strong>Vector Store:</strong> Qdrant for character memories</li>
|
||||
<li>• <strong>Redis:</strong> For caching and session management</li>
|
||||
<li>• <strong>Discord Bot:</strong> Valid bot token with proper permissions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Guide;
|
||||
180
admin-frontend/src/pages/LiveChat.tsx
Normal file
180
admin-frontend/src/pages/LiveChat.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Send, MessageCircle, Users, Bot } from 'lucide-react';
|
||||
import { useWebSocket } from '../contexts/WebSocketContext';
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
character_name?: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
type: 'character' | 'system' | 'user';
|
||||
}
|
||||
|
||||
const LiveChat: React.FC = () => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { connected, activityFeed } = useWebSocket();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
// Convert activity feed to chat messages
|
||||
const chatMessages = activityFeed
|
||||
.filter(activity => activity.type === 'message' || activity.character_name)
|
||||
.map(activity => ({
|
||||
id: activity.id,
|
||||
character_name: activity.character_name,
|
||||
content: activity.description,
|
||||
timestamp: activity.timestamp,
|
||||
type: activity.character_name ? 'character' as const : 'system' as const
|
||||
}))
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
||||
|
||||
setMessages(chatMessages);
|
||||
setLoading(false);
|
||||
}, [activityFeed]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
|
||||
// TODO: Implement sending messages to the system
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user_${Date.now()}`,
|
||||
content: newMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'user'
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setNewMessage('');
|
||||
|
||||
// This would trigger the system to respond
|
||||
console.log('Sending message:', newMessage);
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const getMessageIcon = (message: ChatMessage) => {
|
||||
switch (message.type) {
|
||||
case 'character':
|
||||
return <Bot className="w-4 h-4" />;
|
||||
case 'user':
|
||||
return <Users className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageCircle className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageStyle = (message: ChatMessage) => {
|
||||
switch (message.type) {
|
||||
case 'character':
|
||||
return 'bg-blue-50 border-blue-200';
|
||||
case 'user':
|
||||
return 'bg-green-50 border-green-200';
|
||||
default:
|
||||
return 'bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[calc(100vh-8rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-white">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Live Chat</h1>
|
||||
<p className="text-gray-600">
|
||||
Monitor character conversations in real-time
|
||||
{connected ? (
|
||||
<span className="ml-2 text-green-600">• Connected</span>
|
||||
) : (
|
||||
<span className="ml-2 text-red-600">• Disconnected</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" text="Loading chat..." />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageCircle className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Messages Yet</h3>
|
||||
<p className="text-gray-600">
|
||||
Character conversations will appear here in real-time
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={`p-3 rounded-lg border ${getMessageStyle(message)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getMessageIcon(message)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{message.character_name || (message.type === 'user' ? 'You' : 'System')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="p-4 border-t border-gray-200 bg-white">
|
||||
<form onSubmit={handleSendMessage} className="flex space-x-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Type a message to the characters..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim() || !connected}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{connected
|
||||
? "Messages sent here will be delivered to the character system"
|
||||
: "Connect to start chatting with characters"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveChat;
|
||||
@@ -1,18 +1,580 @@
|
||||
import React from 'react';
|
||||
import { Settings as SettingsIcon } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, AlertCircle, MessageSquare, Brain, Database, Zap, Clock, Shield } from 'lucide-react';
|
||||
import { apiClient } from '../services/api';
|
||||
import LoadingSpinner from '../components/Common/LoadingSpinner';
|
||||
import { LLMProviderSettings } from '../components/LLMProviderSettings';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SystemConfig {
|
||||
// LLM Control (COST PROTECTION)
|
||||
llm_enabled: boolean;
|
||||
conversation_frequency: number;
|
||||
response_delay_min: number;
|
||||
response_delay_max: number;
|
||||
max_conversation_length: number;
|
||||
memory_retention_days: number;
|
||||
creativity_boost: boolean;
|
||||
safety_monitoring: boolean;
|
||||
auto_moderation: boolean;
|
||||
personality_change_rate: number;
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: number;
|
||||
quiet_hours_end: number;
|
||||
min_delay_seconds: number;
|
||||
max_delay_seconds: number;
|
||||
llm_model: string;
|
||||
llm_max_tokens: number;
|
||||
llm_temperature: number;
|
||||
llm_timeout: number;
|
||||
discord_guild_id: string;
|
||||
discord_channel_id: string;
|
||||
}
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await apiClient.getSystemConfig();
|
||||
setConfig(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
toast.error('Failed to load system configuration');
|
||||
// Set default values
|
||||
setConfig({
|
||||
llm_enabled: false, // SAFETY: Default to disabled
|
||||
conversation_frequency: 0.5,
|
||||
response_delay_min: 1.0,
|
||||
response_delay_max: 5.0,
|
||||
max_conversation_length: 50,
|
||||
memory_retention_days: 90,
|
||||
creativity_boost: true,
|
||||
safety_monitoring: false,
|
||||
auto_moderation: false,
|
||||
personality_change_rate: 0.1,
|
||||
quiet_hours_enabled: true,
|
||||
quiet_hours_start: 23,
|
||||
quiet_hours_end: 7,
|
||||
min_delay_seconds: 30,
|
||||
max_delay_seconds: 300,
|
||||
llm_model: 'koboldcpp/Broken-Tutu-24B-Transgression-v2.0.i1-Q4_K_M',
|
||||
llm_max_tokens: 2000,
|
||||
llm_temperature: 0.8,
|
||||
llm_timeout: 300,
|
||||
discord_guild_id: '',
|
||||
discord_channel_id: ''
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (field: keyof SystemConfig, value: any) => {
|
||||
if (!config) return;
|
||||
|
||||
// For LLM enabled changes, save immediately with validation
|
||||
if (field === 'llm_enabled') {
|
||||
const newConfig = { ...config, [field]: value };
|
||||
setConfig(newConfig);
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await apiClient.updateSystemConfig(newConfig);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
// Revert the change
|
||||
setConfig(config);
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} else {
|
||||
setConfig({ ...config, [field]: value });
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await apiClient.updateSystemConfig(config);
|
||||
toast.success('Settings saved successfully');
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
toast.error('Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" text="Loading settings..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-red-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to Load Settings</h3>
|
||||
<p className="text-gray-600">Please try refreshing the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">System Settings</h1>
|
||||
<p className="text-gray-600">Configure the behavior of your character ecosystem</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="ml-2">Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* LLM GLOBAL CONTROL - COST PROTECTION */}
|
||||
<div className={`rounded-lg border-2 p-6 ${config.llm_enabled ? 'bg-green-50 border-green-300' : 'bg-red-50 border-red-300'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-4 h-4 rounded-full ${config.llm_enabled ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
LLM API Status: {config.llm_enabled ? 'ENABLED' : 'DISABLED'}
|
||||
</h3>
|
||||
<p className={`text-sm ${config.llm_enabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{config.llm_enabled
|
||||
? '⚠️ AI API calls are ACTIVE - this costs money!'
|
||||
: '✅ AI API calls are DISABLED - no costs incurred'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{config.llm_enabled ? 'Disable to Save Costs' : 'Enable LLM (will cost money)'}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.llm_enabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked;
|
||||
if (enabled) {
|
||||
const confirmed = window.confirm(
|
||||
'⚠️ WARNING: Enabling LLM will start making API calls that cost money!\n\n' +
|
||||
'Characters will make requests to your AI provider when they chat.\n' +
|
||||
'We will validate your provider configuration first.\n' +
|
||||
'Are you sure you want to enable this?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await handleChange('llm_enabled', enabled);
|
||||
toast[enabled ? 'error' : 'success'](
|
||||
enabled ? '⚠️ LLM ENABLED - API costs will be incurred!' : '✅ LLM DISABLED - No API costs'
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Reset checkbox if enabling failed
|
||||
e.target.checked = false;
|
||||
toast.error(`Failed to enable LLM: ${error.message || 'Validation failed'}`);
|
||||
}
|
||||
}}
|
||||
className={`rounded border-gray-300 focus:ring-2 ${
|
||||
config.llm_enabled ? 'text-red-600 focus:ring-red-500' : 'text-green-600 focus:ring-green-500'
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{config.llm_enabled && (
|
||||
<div className="mt-4 p-3 bg-yellow-100 border border-yellow-300 rounded">
|
||||
<div className="text-sm text-yellow-800">
|
||||
<strong>💰 Cost Alert:</strong> LLM is enabled. Each character message will make an API call to your provider.
|
||||
Monitor your usage and disable when not needed to control costs.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{/* Conversation Settings */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<MessageSquare className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Conversation Settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Conversation Frequency
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={config.conversation_frequency}
|
||||
onChange={(e) => { handleChange('conversation_frequency', parseFloat(e.target.value)).catch(console.error); }}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Rare (0.1)</span>
|
||||
<span className="font-medium">{config.conversation_frequency}</span>
|
||||
<span>Very Frequent (2.0)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
How often characters start new conversations (multiplier for base frequency)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Min Response Delay (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="30"
|
||||
step="0.5"
|
||||
value={config.response_delay_min}
|
||||
onChange={(e) => { handleChange('response_delay_min', parseFloat(e.target.value)).catch(console.error); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Minimum time before responding to a message
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Response Delay (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
step="0.5"
|
||||
value={config.response_delay_max}
|
||||
onChange={(e) => { handleChange('response_delay_max', parseFloat(e.target.value)).catch(console.error); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum time before responding to a message
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Conversation Length (messages)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="200"
|
||||
value={config.max_conversation_length}
|
||||
onChange={(e) => handleChange('max_conversation_length', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum messages in a single conversation thread before wrapping up
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Character Behavior */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Brain className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Character Behavior</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Personality Change Rate
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.01"
|
||||
max="0.5"
|
||||
step="0.01"
|
||||
value={config.personality_change_rate}
|
||||
onChange={(e) => handleChange('personality_change_rate', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Very Stable (0.01)</span>
|
||||
<span className="font-medium">{config.personality_change_rate}</span>
|
||||
<span>Very Dynamic (0.5)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
How much characters' personalities can evolve over time through interactions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.creativity_boost}
|
||||
onChange={(e) => handleChange('creativity_boost', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable Creativity Boost</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Encourages more creative, experimental, and unexpected character responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.safety_monitoring}
|
||||
onChange={(e) => handleChange('safety_monitoring', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable Safety Monitoring</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Monitor conversations for safety and content guidelines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_moderation}
|
||||
onChange={(e) => handleChange('auto_moderation', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable Auto Moderation</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Automatically moderate inappropriate content in conversations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Memory Retention (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={config.memory_retention_days}
|
||||
onChange={(e) => handleChange('memory_retention_days', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
How long characters remember past interactions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing & Scheduling */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Clock className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Timing & Scheduling</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.quiet_hours_enabled}
|
||||
onChange={(e) => handleChange('quiet_hours_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Enable Quiet Hours</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Disable automatic conversations during specified hours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{config.quiet_hours_enabled && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quiet Start (24h format)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={config.quiet_hours_start}
|
||||
onChange={(e) => handleChange('quiet_hours_start', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Hour when quiet time begins
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quiet End (24h format)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={config.quiet_hours_end}
|
||||
onChange={(e) => handleChange('quiet_hours_end', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Hour when quiet time ends
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Min Delay Between Events (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
value={config.min_delay_seconds}
|
||||
onChange={(e) => handleChange('min_delay_seconds', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Minimum time between conversation events
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Delay Between Events (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="30"
|
||||
max="3600"
|
||||
value={config.max_delay_seconds}
|
||||
onChange={(e) => handleChange('max_delay_seconds', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Maximum time between conversation events
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LLM Settings */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Zap className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">LLM Providers</h3>
|
||||
</div>
|
||||
|
||||
<LLMProviderSettings />
|
||||
</div>
|
||||
|
||||
{/* Discord Settings */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Database className="w-5 h-5 text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Discord Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Guild ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.discord_guild_id}
|
||||
onChange={(e) => handleChange('discord_guild_id', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 font-mono"
|
||||
placeholder="110670463348260864"
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Discord server ID where the bot operates (read-only, configured in .env file)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Channel ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.discord_channel_id}
|
||||
onChange={(e) => handleChange('discord_channel_id', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 font-mono"
|
||||
placeholder="1391280548059811900"
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Discord channel ID where characters chat (read-only, configured in .env file)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Reminder */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-4 right-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4 shadow-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-800">You have unsaved changes</span>
|
||||
<button onClick={handleSave} className="btn-primary btn-sm ml-3">
|
||||
Save Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ class ApiClient {
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8294/api',
|
||||
baseURL: process.env.NODE_ENV === 'production' ? `${window.location.protocol}//${window.location.host}/api` : 'http://localhost:8294/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -33,7 +33,7 @@ class ApiClient {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized access
|
||||
this.clearAuthToken();
|
||||
window.location.href = '/admin/login';
|
||||
window.location.href = '/admin/';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -109,6 +109,48 @@ class ApiClient {
|
||||
return this.post(`/characters/${characterName}/resume`);
|
||||
}
|
||||
|
||||
async updateCharacter(characterName: string, characterData: any) {
|
||||
return this.put(`/characters/${characterName}`, characterData);
|
||||
}
|
||||
|
||||
async createCharacter(characterData: any) {
|
||||
return this.post('/characters', characterData);
|
||||
}
|
||||
|
||||
async deleteCharacter(characterName: string) {
|
||||
return this.delete(`/characters/${characterName}`);
|
||||
}
|
||||
|
||||
async toggleCharacterStatus(characterName: string, isActive: boolean) {
|
||||
return this.post(`/characters/${characterName}/toggle`, { is_active: isActive });
|
||||
}
|
||||
|
||||
async bulkCharacterAction(action: string, characterNames: string[]) {
|
||||
return this.post('/characters/bulk-action', { action, character_names: characterNames });
|
||||
}
|
||||
|
||||
async getCharacterFiles(characterName: string, folder: string = '') {
|
||||
const params = folder ? `?folder=${encodeURIComponent(folder)}` : '';
|
||||
return this.get(`/characters/${characterName}/files${params}`);
|
||||
}
|
||||
|
||||
async getCharacterFileContent(characterName: string, filePath: string) {
|
||||
return this.get(`/characters/${characterName}/files/content?file_path=${encodeURIComponent(filePath)}`);
|
||||
}
|
||||
|
||||
// Authentication endpoints
|
||||
async login(username: string, password: string) {
|
||||
return this.post('/auth/login', { username, password });
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return this.post('/auth/logout');
|
||||
}
|
||||
|
||||
async verifyToken() {
|
||||
return this.get('/auth/verify');
|
||||
}
|
||||
|
||||
// Conversation endpoints
|
||||
async getConversations(filters: any = {}) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -172,6 +214,27 @@ class ApiClient {
|
||||
return this.get(`/system/logs?${params}`);
|
||||
}
|
||||
|
||||
// LLM Provider endpoints
|
||||
async getLLMProviders() {
|
||||
return this.get('/system/llm/providers');
|
||||
}
|
||||
|
||||
async updateLLMProviders(providers: any) {
|
||||
return this.put('/system/llm/providers', providers);
|
||||
}
|
||||
|
||||
async testLLMProvider(providerName: string) {
|
||||
return this.post(`/system/llm/providers/${providerName}/test`);
|
||||
}
|
||||
|
||||
async getLLMHealth() {
|
||||
return this.get('/system/llm/health');
|
||||
}
|
||||
|
||||
async switchLLMProvider(providerName: string) {
|
||||
return this.post(`/system/llm/switch/${providerName}`);
|
||||
}
|
||||
|
||||
// Content endpoints
|
||||
async getCreativeWorks(filters: any = {}) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -195,6 +258,53 @@ class ApiClient {
|
||||
async exportCharacterData(characterName: string) {
|
||||
return this.get(`/export/character/${characterName}`);
|
||||
}
|
||||
|
||||
// Prompt template endpoints
|
||||
async getPromptTemplates() {
|
||||
return this.get('/prompt-templates');
|
||||
}
|
||||
|
||||
async createPromptTemplate(templateData: any) {
|
||||
return this.post('/prompt-templates', templateData);
|
||||
}
|
||||
|
||||
async updatePromptTemplate(templateId: number, templateData: any) {
|
||||
return this.put(`/prompt-templates/${templateId}`, templateData);
|
||||
}
|
||||
|
||||
// System prompts and scenarios
|
||||
async getSystemPrompts() {
|
||||
return this.get('/system/prompts');
|
||||
}
|
||||
|
||||
async updateSystemPrompts(prompts: any) {
|
||||
return this.put('/system/prompts', prompts);
|
||||
}
|
||||
|
||||
async getScenarios() {
|
||||
return this.get('/system/scenarios');
|
||||
}
|
||||
|
||||
async createScenario(scenarioData: any) {
|
||||
return this.post('/system/scenarios', scenarioData);
|
||||
}
|
||||
|
||||
async updateScenario(scenarioName: string, scenarioData: any) {
|
||||
return this.put(`/system/scenarios/${scenarioName}`, scenarioData);
|
||||
}
|
||||
|
||||
async deleteScenario(scenarioName: string) {
|
||||
return this.delete(`/system/scenarios/${scenarioName}`);
|
||||
}
|
||||
|
||||
async activateScenario(scenarioName: string) {
|
||||
return this.post(`/system/scenarios/${scenarioName}/activate`);
|
||||
}
|
||||
|
||||
// Admin utilities
|
||||
async fixCharacterPrompts() {
|
||||
return this.post('/admin/fix-character-prompts');
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
Reference in New Issue
Block a user