Spaces:
Sleeping
Sleeping
import React, { useState, useEffect } from 'react'; | |
import { FaPlay, FaSave, FaTimes, FaChevronDown, FaVolumeUp } from 'react-icons/fa'; | |
import './AgentModal.css'; | |
import { BsRobot, BsToggleOff, BsToggleOn } from "react-icons/bs"; | |
import Toast from './Toast'; | |
type Voice = { | |
id: string; | |
name: string; | |
description: string; | |
}; | |
type FormData = { | |
name: string; | |
voice: Voice | null; | |
speed: number; | |
pitch: number; | |
volume: number; | |
outputFormat: 'mp3' | 'wav'; | |
testInput: string; | |
personality: string; | |
showPersonality: boolean; | |
}; | |
const VOICE_OPTIONS: Voice[] = [ | |
{ id: 'alloy', name: 'Alloy', description: 'Versatile, well-rounded voice' }, | |
{ id: 'ash', name: 'Ash', description: 'Direct and clear articulation' }, | |
{ id: 'coral', name: 'Coral', description: 'Warm and inviting tone' }, | |
{ id: 'echo', name: 'Echo', description: 'Balanced and measured delivery' }, | |
{ id: 'fable', name: 'Fable', description: 'Expressive storytelling voice' }, | |
{ id: 'onyx', name: 'Onyx', description: 'Authoritative and professional' }, | |
{ id: 'nova', name: 'Nova', description: 'Energetic and engaging' }, | |
{ id: 'sage', name: 'Sage', description: 'Calm and thoughtful delivery' }, | |
{ id: 'shimmer', name: 'Shimmer', description: 'Bright and optimistic tone' } | |
]; | |
interface AgentModalProps { | |
isOpen: boolean; | |
onClose: () => void; | |
editAgent?: { | |
id: string; | |
name: string; | |
voice_id: string; | |
speed: number; | |
pitch: number; | |
volume: number; | |
output_format: string; | |
personality: string; | |
} | null; | |
} | |
const AgentModal: React.FC<AgentModalProps> = ({ isOpen, onClose, editAgent }) => { | |
const [formData, setFormData] = useState<FormData>({ | |
name: '', | |
voice: null, | |
speed: 1, | |
pitch: 1, | |
volume: 1, | |
outputFormat: 'mp3', | |
testInput: '', | |
personality: '', | |
showPersonality: false | |
}); | |
const [isDropdownOpen, setIsDropdownOpen] = useState(false); | |
const [isLoading, setIsLoading] = useState(false); | |
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null); | |
const [audioPlayer, setAudioPlayer] = useState<HTMLAudioElement | null>(null); | |
const [isTestingVoice, setIsTestingVoice] = useState(false); | |
// Initialize form data when editing an agent | |
useEffect(() => { | |
if (editAgent) { | |
// Find the matching voice from VOICE_OPTIONS | |
const matchingVoice = VOICE_OPTIONS.find(voice => voice.id === editAgent.voice_id) || VOICE_OPTIONS[0]; | |
// Ensure output_format is either 'mp3' or 'wav' | |
const validOutputFormat = editAgent.output_format === 'wav' ? 'wav' : 'mp3'; | |
setFormData({ | |
name: editAgent.name, | |
voice: matchingVoice, | |
speed: editAgent.speed || 1, | |
pitch: editAgent.pitch || 1, | |
volume: editAgent.volume || 1, | |
outputFormat: validOutputFormat, | |
testInput: '', | |
personality: editAgent.personality || '', | |
showPersonality: !!editAgent.personality | |
}); | |
} else { | |
// Reset form when not editing | |
setFormData({ | |
name: '', | |
voice: VOICE_OPTIONS[0], | |
speed: 1, | |
pitch: 1, | |
volume: 1, | |
outputFormat: 'mp3', | |
testInput: '', | |
personality: '', | |
showPersonality: false | |
}); | |
} | |
}, [editAgent]); | |
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target; | |
setFormData(prev => ({ | |
...prev, | |
[name]: value | |
})); | |
}; | |
const handleVoiceSelect = (voice: Voice) => { | |
setFormData(prev => ({ | |
...prev, | |
voice | |
})); | |
setIsDropdownOpen(false); | |
}; | |
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const { name, value } = e.target; | |
const numericValue = parseFloat(value); | |
if (!isNaN(numericValue)) { | |
setFormData(prev => ({ | |
...prev, | |
[name]: numericValue | |
})); | |
} | |
}; | |
const handleTestVoice = async () => { | |
if (!formData.testInput.trim()) { | |
setToast({ message: 'Please enter some text to test', type: 'error' }); | |
return; | |
} | |
try { | |
setIsTestingVoice(true); | |
const token = localStorage.getItem('token'); | |
if (!token) { | |
setToast({ message: 'Authentication token not found', type: 'error' }); | |
setIsTestingVoice(false); | |
return; | |
} | |
// Stop any currently playing audio | |
if (audioPlayer) { | |
audioPlayer.pause(); | |
audioPlayer.src = ''; | |
setAudioPlayer(null); | |
} | |
// Prepare test data | |
const testData = { | |
text: formData.testInput.trim(), | |
voice_id: formData.voice?.id || 'alloy', | |
emotion: 'neutral', // Default emotion | |
speed: formData.speed | |
}; | |
console.log('Sending test data:', JSON.stringify(testData, null, 2)); | |
// Make API request | |
const response = await fetch('http://localhost:8000/agents/test-voice', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${token}` | |
}, | |
body: JSON.stringify(testData) | |
}); | |
console.log('Response status:', response.status); | |
const data = await response.json(); | |
console.log('Response data:', JSON.stringify(data, null, 2)); | |
if (!response.ok) { | |
throw new Error(data.detail || 'Failed to test voice'); | |
} | |
if (!data.audio_url) { | |
throw new Error('No audio URL returned from server'); | |
} | |
setToast({ message: 'Creating audio player...', type: 'info' }); | |
// Create and configure new audio player | |
const newPlayer = new Audio(); | |
// Set up event handlers before setting the source | |
newPlayer.onerror = (e) => { | |
console.error('Audio loading error:', newPlayer.error, e); | |
setToast({ | |
message: `Failed to load audio file: ${newPlayer.error?.message || 'Unknown error'}`, | |
type: 'error' | |
}); | |
setIsTestingVoice(false); | |
}; | |
newPlayer.oncanplaythrough = () => { | |
console.log('Audio can play through, starting playback'); | |
newPlayer.play() | |
.then(() => { | |
setToast({ message: 'Playing test audio', type: 'success' }); | |
}) | |
.catch((error) => { | |
console.error('Playback error:', error); | |
setToast({ | |
message: `Failed to play audio: ${error.message}`, | |
type: 'error' | |
}); | |
setIsTestingVoice(false); | |
}); | |
}; | |
newPlayer.onended = () => { | |
console.log('Audio playback ended'); | |
setIsTestingVoice(false); | |
}; | |
// Log the audio URL we're trying to play | |
console.log('Setting audio source to:', data.audio_url); | |
// Set the source and start loading | |
newPlayer.src = data.audio_url; | |
setAudioPlayer(newPlayer); | |
// Try to load the audio | |
try { | |
await newPlayer.load(); | |
console.log('Audio loaded successfully'); | |
} catch (loadError) { | |
console.error('Error loading audio:', loadError); | |
setToast({ | |
message: `Error loading audio: ${loadError instanceof Error ? loadError.message : 'Unknown error'}`, | |
type: 'error' | |
}); | |
setIsTestingVoice(false); | |
} | |
} catch (error) { | |
console.error('Error testing voice:', error); | |
setToast({ | |
message: error instanceof Error ? error.message : 'Failed to test voice', | |
type: 'error' | |
}); | |
setIsTestingVoice(false); | |
} | |
}; | |
// Cleanup audio player on modal close | |
React.useEffect(() => { | |
return () => { | |
if (audioPlayer) { | |
audioPlayer.pause(); | |
audioPlayer.src = ''; | |
} | |
}; | |
}, [audioPlayer]); | |
const toggleInputType = () => { | |
setFormData(prev => ({ | |
...prev, | |
showPersonality: !prev.showPersonality | |
})); | |
}; | |
const handleSubmit = async (e: React.FormEvent) => { | |
e.preventDefault(); | |
if (!formData.voice) { | |
setToast({ message: 'Please select a voice', type: 'error' }); | |
return; | |
} | |
try { | |
const token = localStorage.getItem('token'); | |
if (!token) { | |
setToast({ message: 'Authentication token not found', type: 'error' }); | |
return; | |
} | |
const requestData = { | |
name: formData.name, | |
voice_id: formData.voice.id, | |
voice_name: formData.voice.name, | |
voice_description: formData.voice.description, | |
speed: formData.speed, | |
pitch: formData.pitch, | |
volume: formData.volume, | |
output_format: formData.outputFormat, // Use snake_case to match backend | |
personality: formData.showPersonality ? formData.personality : null | |
}; | |
console.log('Request data:', JSON.stringify(requestData, null, 2)); | |
const url = editAgent | |
? `http://localhost:8000/agents/${editAgent.id}` | |
: 'http://localhost:8000/agents/create'; | |
const response = await fetch(url, { | |
method: editAgent ? 'PUT' : 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${token}` | |
}, | |
body: JSON.stringify(requestData) | |
}); | |
const responseData = await response.json(); | |
console.log('Response data:', JSON.stringify(responseData, null, 2)); | |
if (!response.ok) { | |
throw new Error(JSON.stringify(responseData, null, 2)); | |
} | |
setToast({ message: `Agent ${editAgent ? 'updated' : 'created'} successfully`, type: 'success' }); | |
onClose(); | |
} catch (error) { | |
console.error('Error saving agent:', error); | |
if (error instanceof Error) { | |
console.error('Error details:', error.message); | |
try { | |
const errorDetails = JSON.parse(error.message); | |
setToast({ | |
message: errorDetails.detail?.[0]?.msg || 'Failed to save agent', | |
type: 'error' | |
}); | |
} catch { | |
setToast({ message: error.message, type: 'error' }); | |
} | |
} else { | |
setToast({ message: 'An unexpected error occurred', type: 'error' }); | |
} | |
} | |
}; | |
if (!isOpen) return null; | |
return ( | |
<div className="agent-modal-overlay" style={{ display: isOpen ? 'flex' : 'none' }}> | |
<div className="agent-modal-content"> | |
<div className="agent-modal-header"> | |
<h2>{editAgent ? 'Edit Agent' : 'Create New Agent'}</h2> | |
<button className="close-button" onClick={onClose}>×</button> | |
</div> | |
<form className="agent-form" onSubmit={handleSubmit}> | |
<div className="form-group"> | |
<label htmlFor="name">Agent Name</label> | |
<input | |
type="text" | |
id="name" | |
name="name" | |
value={formData.name} | |
onChange={handleInputChange} | |
placeholder="Enter agent name" | |
required | |
/> | |
</div> | |
<div className="form-group"> | |
<label>Voice</label> | |
<div className="custom-dropdown"> | |
<div | |
className="dropdown-header" | |
onClick={() => setIsDropdownOpen(!isDropdownOpen)} | |
> | |
<div className="selected-voice"> | |
<FaVolumeUp /> | |
<div className="voice-info"> | |
<span>{formData.voice?.name}</span> | |
<small>{formData.voice?.description}</small> | |
</div> | |
</div> | |
<FaChevronDown style={{ | |
transform: isDropdownOpen ? 'rotate(180deg)' : 'none', | |
transition: 'transform 0.3s ease' | |
}} /> | |
</div> | |
{isDropdownOpen && ( | |
<div className="dropdown-options"> | |
{VOICE_OPTIONS.map(voice => ( | |
<div | |
key={voice.id} | |
className="dropdown-option" | |
onClick={() => handleVoiceSelect(voice)} | |
> | |
<FaVolumeUp /> | |
<div className="voice-info"> | |
<span>{voice.name}</span> | |
<small>{voice.description}</small> | |
</div> | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
<div className="form-group"> | |
<label htmlFor="speed">Speed</label> | |
<div className="slider-container"> | |
<input | |
type="range" | |
id="speed" | |
name="speed" | |
min="0.5" | |
max="2" | |
step="0.1" | |
value={formData.speed} | |
onChange={handleSliderChange} | |
/> | |
<span className="slider-value">{formData.speed}x</span> | |
</div> | |
</div> | |
<div className="form-group"> | |
<label htmlFor="pitch">Pitch</label> | |
<div className="slider-container"> | |
<input | |
type="range" | |
id="pitch" | |
name="pitch" | |
min="0.5" | |
max="2" | |
step="0.1" | |
value={formData.pitch} | |
onChange={handleSliderChange} | |
/> | |
<span className="slider-value">{formData.pitch}x</span> | |
</div> | |
</div> | |
<div className="form-group"> | |
<label htmlFor="volume">Volume</label> | |
<div className="slider-container"> | |
<input | |
type="range" | |
id="volume" | |
name="volume" | |
min="0" | |
max="2" | |
step="0.1" | |
value={formData.volume} | |
onChange={handleSliderChange} | |
/> | |
<span className="slider-value">{formData.volume}x</span> | |
</div> | |
</div> | |
<div className="form-group"> | |
<label>Output Format</label> | |
<div className="radio-group"> | |
<label className="radio-label"> | |
<input | |
type="radio" | |
name="outputFormat" | |
value="mp3" | |
checked={formData.outputFormat === 'mp3'} | |
onChange={handleInputChange} | |
/> | |
<span>MP3</span> | |
</label> | |
<label className="radio-label"> | |
<input | |
type="radio" | |
name="outputFormat" | |
value="wav" | |
checked={formData.outputFormat === 'wav'} | |
onChange={handleInputChange} | |
/> | |
<span>WAV</span> | |
</label> | |
</div> | |
</div> | |
<div className="form-group toggle-group"> | |
<label>Input Type</label> | |
<div className="toggle-container" onClick={toggleInputType}> | |
<span className={!formData.showPersonality ? 'active' : ''}>Test Input</span> | |
{formData.showPersonality ? | |
<BsToggleOn className="toggle-icon" /> : | |
<BsToggleOff className="toggle-icon" /> | |
} | |
<span className={formData.showPersonality ? 'active' : ''}>Agent Personality</span> | |
</div> | |
</div> | |
{formData.showPersonality ? ( | |
<div className="form-group"> | |
<label htmlFor="personality">Agent Personality</label> | |
<textarea | |
id="personality" | |
name="personality" | |
value={formData.personality} | |
onChange={handleInputChange} | |
placeholder="Describe the personality and characteristics of this agent..." | |
rows={4} | |
/> | |
<small className="help-text">This personality description will be used to guide the agent's responses in workflows.</small> | |
</div> | |
) : ( | |
<div className="form-group"> | |
<label htmlFor="testInput">Test Input</label> | |
<textarea | |
id="testInput" | |
name="testInput" | |
value={formData.testInput} | |
onChange={handleInputChange} | |
placeholder="Enter text to test the voice" | |
rows={4} | |
/> | |
</div> | |
)} | |
<div className="modal-actions"> | |
{!formData.showPersonality && ( | |
<button | |
type="button" | |
className="test-voice-btn" | |
onClick={handleTestVoice} | |
disabled={!formData.testInput || isTestingVoice} | |
> | |
<FaPlay /> {isTestingVoice ? 'Testing...' : 'Test Voice'} | |
</button> | |
)} | |
<div className="right-actions"> | |
<button type="button" className="cancel-btn" onClick={onClose}> | |
Cancel | |
</button> | |
<button | |
type="submit" | |
className="save-btn" | |
disabled={isLoading} | |
> | |
<FaSave /> {isLoading ? 'Saving...' : 'Save Agent'} | |
</button> | |
</div> | |
</div> | |
</form> | |
{toast && ( | |
<Toast | |
message={toast.message} | |
type={toast.type} | |
onClose={() => setToast(null)} | |
/> | |
)} | |
</div> | |
</div> | |
); | |
}; | |
export default AgentModal; |