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:
root
2025-07-08 07:35:48 -07:00
parent 004f0325ec
commit 10563900a3
59 changed files with 6686 additions and 791 deletions

View 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"
]
}
}

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

View File

@@ -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>
);

View File

@@ -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;

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

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

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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 {

View File

@@ -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

View 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;

View File

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

View File

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

View 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;

View 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;

View File

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

View File

@@ -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();