Spaces:
Sleeping
Sleeping
import React, { useState, useCallback, useEffect, useRef } from 'react'; | |
import { useParams, useNavigate } from 'react-router-dom'; | |
import ReactFlow, { | |
MiniMap, | |
Controls, | |
Background, | |
useNodesState, | |
useEdgesState, | |
addEdge, | |
isNode, | |
MarkerType, | |
BackgroundVariant, | |
applyEdgeChanges, | |
applyNodeChanges | |
} from 'reactflow'; | |
import { FaArrowLeft, FaSave, FaTrash, FaPlay, FaTimes, FaPencilAlt, FaCheck, FaPause, FaVolumeUp, FaUserCog, FaPlus } from 'react-icons/fa'; | |
import { TiFlowMerge } from "react-icons/ti"; | |
import { RiRobot2Fill } from "react-icons/ri"; | |
import { BsToggle2Off, BsToggle2On } from "react-icons/bs"; | |
import AgentModal from './AgentModal'; | |
import WorkflowToast from './WorkflowToast'; | |
import NodeSelectionPanel from './NodeSelectionPanel'; | |
import InputNodeModal from './InputNodeModal'; | |
import customNodeTypes from './CustomNodes'; // Import our custom node types | |
import CustomEdge, { customEdgeTypes } from './CustomEdge'; // Import our custom edge component | |
import 'reactflow/dist/style.css'; | |
import './WorkflowEditor.css'; | |
const initialNodes = []; | |
const initialEdges = []; | |
const DEFAULT_AGENTS = [ | |
{ id: 'researcher', name: 'Research Agent', status: 'Default', isDefault: true }, | |
{ id: 'believer', name: 'Believer Agent', status: 'Default', isDefault: true }, | |
{ id: 'skeptic', name: 'Skeptic Agent', status: 'Default', isDefault: true } | |
]; | |
// Define toast type | |
const initialToastState = { | |
message: '', | |
type: 'success' | |
}; | |
// Define node data types with proper interface | |
const createNodeData = (label, additionalData = {}) => { | |
// Create basic data object with default values | |
const data = { | |
label, | |
description: '', | |
...additionalData | |
}; | |
return data; | |
}; | |
// Create a typed node creator function | |
const createNode = (id, position, data, nodeType = 'default') => { | |
const node = { | |
id, | |
position, | |
data, | |
type: nodeType | |
}; | |
return node; | |
}; | |
// Connection validation rules | |
const isValidConnection = (connection, nodes) => { | |
const { source, target } = connection; | |
// Get source and target nodes | |
const sourceNode = nodes.find(node => node.id === source); | |
const targetNode = nodes.find(node => node.id === target); | |
if (!sourceNode || !targetNode) return false; | |
// Get node types | |
const sourceType = sourceNode.type; | |
const targetType = targetNode.type; | |
// Define connection rules | |
// 1. Input Node can only output to Researcher nodes | |
if (sourceType === 'input' && targetType !== 'researcher') { | |
return false; | |
} | |
// 2. Researcher Node can take input from Input nodes and output to Agent nodes or Insights nodes | |
if (sourceType === 'researcher' && (targetType !== 'agent' && targetType !== 'insights')) { | |
return false; | |
} | |
if (targetType === 'researcher' && sourceType !== 'input') { | |
return false; | |
} | |
// 3. Agent Nodes can only output to Insights nodes | |
if (sourceType === 'agent' && targetType !== 'insights') { | |
return false; | |
} | |
// 4. Insights Node can only take inputs from Agent nodes or Researcher nodes and output to Notify or Publish | |
if (sourceType === 'insights' && targetType !== 'notify' && targetType !== 'output') { | |
return false; | |
} | |
if (targetType === 'insights' && sourceType !== 'agent' && sourceType !== 'researcher') { | |
return false; | |
} | |
// 5. Notify and Publish nodes can only take input from Insights | |
if ((targetType === 'notify' || targetType === 'output') && sourceType !== 'insights') { | |
return false; | |
} | |
return true; | |
}; | |
const WorkflowEditor = () => { | |
const { workflowId } = useParams(); | |
const navigate = useNavigate(); | |
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); | |
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); | |
const [workflowName, setWorkflowName] = useState(''); | |
const [isEditingName, setIsEditingName] = useState(workflowId === '-1'); | |
const [tempWorkflowName, setTempWorkflowName] = useState(''); | |
const [isAgentModalOpen, setIsAgentModalOpen] = useState(false); | |
const [agents, setAgents] = useState(DEFAULT_AGENTS); | |
const [isLoadingAgents, setIsLoadingAgents] = useState(false); | |
const [selectedAgent, setSelectedAgent] = useState(null); | |
const [toast, setToast] = useState(initialToastState); | |
const [isInsightsEnabled, setIsInsightsEnabled] = useState(false); | |
const [podcastText, setPodcastText] = useState(''); | |
const [selectedVoice, setSelectedVoice] = useState('alloy'); | |
const [selectedEmotion, setSelectedEmotion] = useState('neutral'); | |
const [voiceSpeed, setVoiceSpeed] = useState(1.0); | |
const [isGenerating, setIsGenerating] = useState(false); | |
const [audioUrl, setAudioUrl] = useState(''); | |
const [isPlaying, setIsPlaying] = useState(false); | |
const [currentTime, setCurrentTime] = useState(0); | |
const [duration, setDuration] = useState(0); | |
const [successMessage, setSuccessMessage] = useState(''); | |
const audioRef = useRef(null); | |
// New state variables for workflow execution | |
const [executionState, setExecutionState] = useState({}); | |
const [isExecuting, setIsExecuting] = useState(false); | |
const [workflowInsights, setWorkflowInsights] = useState(""); | |
// State for ReactFlow instance | |
const [rfInstance, setRfInstance] = useState(null); | |
// Default edge options for animated flow | |
const defaultEdgeOptions = { | |
type: 'custom', | |
animated: true, | |
style: { stroke: '#6366f1', strokeWidth: 2 }, // Default color, will be overridden contextually | |
markerEnd: { | |
type: MarkerType.ArrowClosed, | |
color: '#6366f1', // Default color, will be overridden contextually | |
width: 20, | |
height: 20, | |
} | |
}; | |
// Edge types definition | |
const edgeTypes = { ...customEdgeTypes }; | |
const voices = [ | |
{ id: 'alloy', name: 'Alloy' }, | |
{ id: 'echo', name: 'Echo' }, | |
{ id: 'fable', name: 'Fable' }, | |
{ id: 'onyx', name: 'Onyx' }, | |
{ id: 'nova', name: 'Nova' }, | |
{ id: 'shimmer', name: 'Shimmer' } | |
]; | |
const emotions = [ | |
{ id: 'neutral', name: 'Neutral' }, | |
{ id: 'happy', name: 'Happy' }, | |
{ id: 'sad', name: 'Sad' }, | |
{ id: 'excited', name: 'Excited' }, | |
{ id: 'calm', name: 'Calm' } | |
]; | |
const onConnect = useCallback( | |
(params) => { | |
// Check if connection is valid | |
if (isValidConnection(params, nodes)) { | |
// Get the source node to determine edge color | |
const sourceNode = nodes.find(n => n.id === params.source); | |
// Set color based on source node type | |
let edgeColor = '#6366f1'; // Default color (purple) | |
if (sourceNode) { | |
switch (sourceNode.type) { | |
case 'input': | |
edgeColor = '#6366F1'; // Input -> purple | |
break; | |
case 'researcher': | |
edgeColor = '#4C1D95'; // Researcher -> deep purple | |
break; | |
case 'agent': | |
edgeColor = '#10B981'; // Agent -> green | |
break; | |
case 'insights': | |
edgeColor = '#F59E0B'; // Insights -> amber | |
break; | |
default: | |
edgeColor = '#6366f1'; // Default | |
} | |
} | |
// Create an animated edge with styling | |
const edge = { | |
...params, | |
type: 'custom', // Use our custom edge type | |
animated: true, | |
style: { stroke: edgeColor, strokeWidth: 2 }, | |
markerEnd: { | |
type: MarkerType.ArrowClosed, | |
color: edgeColor, | |
width: 20, | |
height: 20, | |
}, | |
data: { sourceType: sourceNode ? sourceNode.type : 'unknown' } | |
}; | |
setEdges((eds) => addEdge(edge, eds)); | |
} else { | |
// Show error message about invalid connection | |
setToast({ | |
message: 'Invalid connection: Node types cannot be connected in this way', | |
type: 'error' | |
}); | |
} | |
}, | |
[nodes, setEdges, setToast] | |
); | |
const onNodeClick = useCallback((event, node) => { | |
// Check if the clicked node is an input node | |
if (node.type === 'input') { | |
setSelectedInputNode(node.id); | |
setIsInputModalOpen(true); | |
} | |
}, []); | |
const handleSaveWorkflow = async () => { | |
if (!workflowName.trim()) { | |
setToast({ | |
message: 'Please enter a workflow name', | |
type: 'error' | |
}); | |
return; | |
} | |
try { | |
const token = localStorage.getItem('token'); | |
const workflowData = { | |
name: workflowName, | |
description: '', | |
nodes: nodes, | |
edges: edges | |
}; | |
const url = workflowId === '-1' | |
? 'http://localhost:8000/api/workflows' | |
: `http://localhost:8000/api/workflows/${workflowId}`; | |
const method = workflowId === '-1' ? 'POST' : 'PUT'; | |
const response = await fetch(url, { | |
method: method, | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${token}` | |
}, | |
body: JSON.stringify(workflowData) | |
}); | |
if (!response.ok) { | |
throw new Error('Failed to save workflow'); | |
} | |
const savedWorkflow = await response.json(); | |
// Update workflowId if this was a new workflow | |
if (workflowId === '-1') { | |
navigate(`/workflows/workflow/${savedWorkflow.id}`, { replace: true }); | |
} | |
setToast({ | |
message: 'Workflow saved successfully!', | |
type: 'success' | |
}); | |
} catch (error) { | |
console.error('Error saving workflow:', error); | |
setToast({ | |
message: 'Failed to save workflow', | |
type: 'error' | |
}); | |
} | |
}; | |
const handleClearWorkflow = () => { | |
setNodes([]); | |
setEdges([]); | |
}; | |
const handleExecuteWorkflow = async () => { | |
// Prevent multiple executions | |
if (isExecuting) { | |
setToast({ | |
message: 'Workflow is already executing', | |
type: 'info' | |
}); | |
return; | |
} | |
// Initial validation | |
if (nodes.length === 0) { | |
setToast({ | |
message: 'No nodes to execute in the workflow', | |
type: 'error' | |
}); | |
return; | |
} | |
// Check for input nodes | |
const inputNodes = nodes.filter(node => node.type === 'input'); | |
if (inputNodes.length === 0) { | |
setToast({ | |
message: 'Workflow must contain at least one input node', | |
type: 'error' | |
}); | |
return; | |
} | |
// Check for missing connections | |
const unconnectedNodes = nodes.filter(node => { | |
if (node.type === 'input') return false; // Input nodes don't need incoming connections | |
const incomingEdges = edges.filter(edge => edge.target === node.id); | |
return incomingEdges.length === 0; | |
}); | |
if (unconnectedNodes.length > 0) { | |
setToast({ | |
message: 'Some nodes are not connected - please complete the workflow', | |
type: 'error' | |
}); | |
return; | |
} | |
// Set up execution state | |
setIsExecuting(true); | |
setWorkflowInsights(""); // Clear previous insights | |
// Initialize execution state for all nodes | |
const initialState = {}; | |
nodes.forEach(node => { | |
initialState[node.id] = { | |
status: 'pending', // pending, in-progress, completed, error | |
result: null, | |
error: null | |
}; | |
}); | |
setExecutionState(initialState); | |
try { | |
// Update UI to show we're starting | |
setToast({ | |
message: 'Starting workflow execution...', | |
type: 'info' | |
}); | |
// Find the topological order of nodes for execution | |
const nodeOrder = getTopologicalOrder(nodes, edges); | |
// Execute nodes in topological order | |
for (const nodeId of nodeOrder) { | |
const node = nodes.find(n => n.id === nodeId); | |
if (!node) { | |
throw new Error(`Node with ID ${nodeId} not found`); | |
} | |
// Update node status to in-progress | |
setExecutionState(prev => { | |
const newState = Object.assign({}, prev); | |
if (prev[nodeId]) { | |
newState[nodeId] = Object.assign({}, prev[nodeId], { status: 'in-progress' }); | |
} else { | |
newState[nodeId] = { status: 'in-progress', result: null, error: null }; | |
} | |
return newState; | |
}); | |
// Get input for this node from previous nodes | |
const nodeInputs = await getNodeInputs(nodeId, edges, executionState); | |
// Execute this node | |
const result = await executeNode(node, nodeInputs); | |
// Update execution state with result | |
setExecutionState(prev => { | |
const newState = Object.assign({}, prev); | |
newState[nodeId] = { status: 'completed', result, error: null }; | |
return newState; | |
}); | |
// If this is an insights node, update the insights panel | |
if (node.type === 'insights') { | |
setWorkflowInsights(result); | |
} | |
// Small delay between nodes for visual effect | |
await new Promise(resolve => setTimeout(resolve, 500)); | |
} | |
setToast({ | |
message: 'Workflow execution completed successfully', | |
type: 'success' | |
}); | |
} catch (error) { | |
console.error('Error executing workflow:', error); | |
setToast({ | |
message: `Workflow execution failed: ${error.message}`, | |
type: 'error' | |
}); | |
} finally { | |
setIsExecuting(false); | |
} | |
}; | |
// Gets the topological order of nodes for execution | |
const getTopologicalOrder = (nodes, edges) => { | |
// Create a graph representation | |
const graph = {}; | |
const inDegree = {}; | |
// Initialize | |
nodes.forEach(node => { | |
graph[node.id] = []; | |
inDegree[node.id] = 0; | |
}); | |
// Build the graph | |
edges.forEach(edge => { | |
graph[edge.source].push(edge.target); | |
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1; | |
}); | |
// Find nodes with no incoming edges (sources) | |
const queue = nodes | |
.filter(node => inDegree[node.id] === 0) | |
.map(node => node.id); | |
const result = []; | |
// Process the queue | |
while (queue.length > 0) { | |
const nodeId = queue.shift(); | |
result.push(nodeId); | |
// For each neighbor, decrease in-degree and check if it becomes a source | |
graph[nodeId].forEach(neighbor => { | |
inDegree[neighbor]--; | |
if (inDegree[neighbor] === 0) { | |
queue.push(neighbor); | |
} | |
}); | |
} | |
// Check if we visited all nodes | |
if (result.length !== nodes.length) { | |
throw new Error('Workflow contains cycles, cannot determine execution order'); | |
} | |
return result; | |
}; | |
// Get inputs for a node from its incoming edges | |
const getNodeInputs = async (nodeId, edges, executionState) => { | |
// Find all edges that point to this node | |
const incomingEdges = edges.filter(edge => edge.target === nodeId); | |
// For each incoming edge, get the output of the source node | |
const inputs = {}; | |
for (const edge of incomingEdges) { | |
const sourceId = edge.source; | |
const sourceState = executionState[sourceId]; | |
if (!sourceState || sourceState.status !== 'completed') { | |
throw new Error(`Source node ${sourceId} has not completed execution`); | |
} | |
inputs[sourceId] = sourceState.result; | |
} | |
return inputs; | |
}; | |
// Execute a single node | |
const executeNode = async (node, inputs) => { | |
// Based on node type, execute the appropriate logic | |
switch (node.type) { | |
case 'input': | |
// For input nodes, just return the prompt | |
return node.data.prompt || "No input provided"; | |
case 'researcher': | |
// Call the research API | |
return await executeResearcherNode(node.data.prompt || inputs[Object.keys(inputs)[0]]); | |
case 'agent': | |
// Execute an agent based on research results | |
return await executeAgentNode(node, inputs); | |
case 'insights': | |
// Process agent outputs to generate insights | |
return await generateInsights(inputs); | |
case 'notify': | |
// Notify functionality (dummy for now) | |
console.log("Notification would be sent:", inputs); | |
return "Notification sent"; | |
case 'output': | |
// Publishing functionality (dummy for now) | |
console.log("Content would be published:", inputs); | |
return "Content published"; | |
default: | |
throw new Error(`Unknown node type: ${node.type}`); | |
} | |
}; | |
// Execute the researcher node | |
const executeResearcherNode = async (topic) => { | |
try { | |
const token = localStorage.getItem('token'); | |
// For now, we'll simulate the research by using the same endpoint used for podcast generation | |
// In a real implementation, this would be a dedicated research endpoint | |
setToast({ | |
message: 'Researching topic...', | |
type: 'info' | |
}); | |
const researchData = { | |
topic: topic, | |
max_tokens: 1000, | |
research_only: true // Hypothetical flag to only do research | |
}; | |
// This is a simplification - in a real implementation, we'd have a dedicated endpoint | |
const response = await fetch('http://localhost:8000/generate-podcast', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${token}` | |
}, | |
body: JSON.stringify(researchData) | |
}); | |
if (!response.ok) { | |
throw new Error(`Research failed with status: ${response.status}`); | |
} | |
const result = await response.json(); | |
// For demo purposes, simulate a research result if the API doesn't provide one | |
const researchText = result.research || | |
`## Research on: ${topic}\n\nThis is simulated research content for demonstration purposes. In a real implementation, this would contain actual research data retrieved from various sources about "${topic}".`; | |
return researchText; | |
} catch (error) { | |
console.error("Error in researcher node:", error); | |
// For demo purposes, return a fallback response | |
return `## Research on: ${topic}\n\nThis is simulated research content for demonstration purposes. In a real implementation, this would contain actual research data retrieved from various sources.`; | |
} | |
}; | |
// Execute an agent node | |
const executeAgentNode = async (node, inputs) => { | |
try { | |
const token = localStorage.getItem('token'); | |
const agentId = node.data.agentId; | |
const agentName = node.data.label || "Agent"; | |
// Get the first input (research result) | |
const research = Object.values(inputs)[0]; | |
setToast({ | |
message: `Agent "${agentName}" is processing...`, | |
type: 'info' | |
}); | |
// In a real implementation, we'd use a dedicated agent endpoint | |
// For now, we'll simulate the agent response | |
// Wait a bit to simulate processing | |
await new Promise(resolve => setTimeout(resolve, 1500)); | |
// Generate a simulated response based on agent type | |
let agentResponse = ""; | |
if (agentId === 'believer') { | |
agentResponse = `## Believer Agent's Perspective\n\nAs a believer in this topic, I think there are several important points to consider:\n\n1. The research shows promising evidence for this concept\n2. There are multiple credible sources supporting this view\n3. The historical patterns suggest this trend will continue\n\nI believe the data clearly demonstrates the validity of this approach.`; | |
} else if (agentId === 'skeptic') { | |
agentResponse = `## Skeptic Agent's Response\n\nI remain unconvinced by several aspects of this research:\n\n1. There are methodological flaws in some of the cited studies\n2. Alternative explanations haven't been fully explored\n3. The evidence isn't as robust as it initially appears\n\nWe should maintain healthy skepticism until more conclusive data is available.`; | |
} else if (agentId === 'researcher') { | |
agentResponse = `## Research Agent's Analysis\n\nBased on the collected information, I've identified these key findings:\n\n1. The topic has been studied across multiple disciplines\n2. There are both supporting and contradicting viewpoints\n3. Recent developments suggest new avenues for investigation\n\nFurther research is recommended in the following areas: [list of suggested research directions]`; | |
} else { | |
agentResponse = `## ${agentName}'s Analysis\n\nI've processed the research and have the following insights to share:\n\n1. This topic presents interesting challenges and opportunities\n2. Several patterns emerge from the available data\n3. We should consider multiple perspectives when evaluating this information\n\nMy conclusion is that this subject deserves more nuanced discussion.`; | |
} | |
return agentResponse; | |
} catch (error) { | |
console.error("Error in agent node:", error); | |
// For demo purposes, return a fallback response | |
return `## Agent Response\n\nThis is a simulated agent response for demonstration purposes. In a real implementation, this would contain the agent's analysis of the research.`; | |
} | |
}; | |
// Generate insights from agent outputs | |
const generateInsights = async (inputs) => { | |
try { | |
// Get agent outputs as an array | |
const agentOutputs = Object.values(inputs); | |
setToast({ | |
message: 'Generating insights...', | |
type: 'info' | |
}); | |
// Wait a bit to simulate processing | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
// Create a nicely formatted insights summary | |
let insightsContent = `# Workflow Insights Summary\n\n`; | |
// Add a section for each agent output | |
agentOutputs.forEach((output, index) => { | |
// Extract the first heading if it exists | |
const headingMatch = String(output).match(/##\s+(.*?)(\n|$)/); | |
const heading = headingMatch ? headingMatch[1] : `Agent ${index + 1} Insights`; | |
insightsContent += `## ${heading}\n\n`; | |
// Add the output content, removing the original heading | |
const contentWithoutHeading = String(output).replace(/##\s+(.*?)(\n|$)/, '').trim(); | |
insightsContent += `${contentWithoutHeading}\n\n`; | |
}); | |
// Add a conclusion section | |
insightsContent += `## Conclusion\n\nThe workflow has processed inputs through research and agent analysis, producing the insights above. These represent different perspectives and analyses of the topic.`; | |
return insightsContent; | |
} catch (error) { | |
console.error("Error generating insights:", error); | |
// For demo purposes, return a fallback response | |
return `# Workflow Insights Summary\n\nThis is a simulated insights summary for demonstration purposes. In a real implementation, this would contain a comprehensive analysis of all agent outputs.`; | |
} | |
}; | |
const handleAddNode = () => { | |
setIsNodePanelOpen(true); | |
}; | |
const handleNodeSelect = ({ type, agentId }) => { | |
const nodeCount = nodes.length; | |
const newNodeId = `${nodeCount + 1}`; | |
// Calculate grid-like position for better layout | |
// Start with a decent offset and use a grid pattern for placement | |
const columnWidth = 250; | |
const rowHeight = 150; | |
const columnsPerRow = 3; | |
const column = nodeCount % columnsPerRow; | |
const row = Math.floor(nodeCount / columnsPerRow); | |
const position = { | |
x: 50 + (column * columnWidth), | |
y: 50 + (row * rowHeight) | |
}; | |
// Configure node based on type | |
switch (type) { | |
case 'input': | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData('Input Prompt', { | |
description: 'Enter your podcast topic or question' | |
}), | |
'input' | |
)]); | |
break; | |
case 'agent': | |
const selectedAgent = agents.find(a => a.id === agentId); | |
// Special handling for researcher agent | |
if (selectedAgent && selectedAgent.id === 'researcher') { | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData( | |
'Research Agent', | |
{ | |
agentId, | |
description: 'Gathers information and research data' | |
} | |
), | |
'researcher' | |
)]); | |
} else { | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData( | |
selectedAgent ? `${selectedAgent.name}` : 'AI Agent', | |
{ | |
agentId, | |
description: selectedAgent ? `${selectedAgent.status} agent` : 'Processes and enhances content' | |
} | |
), | |
'agent' | |
)]); | |
} | |
break; | |
case 'insights': | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData('Insights Analysis', { | |
description: 'Extract key findings and trends' | |
}), | |
'insights' | |
)]); | |
break; | |
case 'notify': | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData('Notification', { | |
description: 'Sends alerts when processing completes' | |
}), | |
'notify' | |
)]); | |
break; | |
case 'publish': | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData('YouTube Publication', { | |
description: 'Publish podcast to your channel' | |
}), | |
'output' | |
)]); | |
break; | |
default: | |
setNodes((nds) => [...nds, createNode( | |
newNodeId, | |
position, | |
createNodeData(`Node ${newNodeId}`, { | |
description: 'Custom workflow node' | |
}) | |
)]); | |
} | |
}; | |
const handleEditName = () => { | |
setTempWorkflowName(workflowName); | |
setIsEditingName(true); | |
}; | |
const handleSaveName = () => { | |
if (tempWorkflowName.trim()) { | |
setWorkflowName(tempWorkflowName.trim()); | |
setIsEditingName(false); | |
} | |
}; | |
const handleCancelNameEdit = () => { | |
setTempWorkflowName(workflowName); | |
setIsEditingName(false); | |
}; | |
const handleCreateAgents = () => { | |
setIsAgentModalOpen(true); | |
}; | |
const handleAgentClick = (agent) => { | |
if (!agent.isDefault) { | |
setSelectedAgent(agent); | |
setIsAgentModalOpen(true); | |
} | |
}; | |
// Load custom agents | |
const loadCustomAgents = async () => { | |
try { | |
setIsLoadingAgents(true); | |
const token = localStorage.getItem('token'); | |
if (!token) { | |
console.error('No token found'); | |
return; | |
} | |
const response = await fetch('http://localhost:8000/agents', { | |
headers: { | |
'Authorization': `Bearer ${token}` | |
} | |
}); | |
if (!response.ok) { | |
throw new Error('Failed to load agents'); | |
} | |
const customAgents = await response.json(); | |
const formattedCustomAgents = customAgents.map(agent => ({ | |
id: agent.agent_id, | |
name: agent.name, | |
voice_id: agent.voice_id, | |
status: 'Ready', | |
isDefault: false | |
})); | |
// Combine default and custom agents | |
setAgents([...DEFAULT_AGENTS, ...formattedCustomAgents]); | |
} catch (error) { | |
console.error('Error loading agents:', error); | |
} finally { | |
setIsLoadingAgents(false); | |
} | |
}; | |
// Load agents when modal closes | |
const handleAgentModalClose = () => { | |
setIsAgentModalOpen(false); | |
setSelectedAgent(null); | |
loadCustomAgents(); // Reload agents after modal closes | |
}; | |
const handleInputSubmit = (nodeId, prompt) => { | |
setNodes((nds) => | |
nds.map((node) => { | |
if (node.id === nodeId) { | |
return { | |
...node, | |
data: { | |
...node.data, | |
prompt: prompt, | |
label: `Input: ${prompt.substring(0, 20)}${prompt.length > 20 ? '...' : ''}` | |
} | |
}; | |
} | |
return node; | |
}) | |
); | |
}; | |
// Load workflow data if editing an existing workflow | |
useEffect(() => { | |
const loadWorkflow = async () => { | |
if (workflowId === '-1') return; // Skip loading for new workflows | |
try { | |
const token = localStorage.getItem('token'); | |
const response = await fetch(`http://localhost:8000/api/workflows/${workflowId}`, { | |
headers: { | |
'Authorization': `Bearer ${token}` | |
} | |
}); | |
if (!response.ok) { | |
throw new Error('Failed to load workflow'); | |
} | |
const workflow = await response.json(); | |
setWorkflowName(workflow.name); | |
setTempWorkflowName(workflow.name); | |
if (workflow.nodes) setNodes(workflow.nodes); | |
if (workflow.edges) { | |
// Apply animation and contextual colors to existing edges | |
const nodesMap = workflow.nodes ? workflow.nodes.reduce((acc, node) => { | |
acc[node.id] = node; | |
return acc; | |
}, {}) : {}; | |
const animatedEdges = workflow.edges.map(edge => { | |
// Determine source node to get its type | |
const sourceNode = nodesMap[edge.source]; | |
// Set color based on source node type | |
let edgeColor = '#6366f1'; // Default color (purple) | |
if (sourceNode) { | |
switch (sourceNode.type) { | |
case 'input': | |
edgeColor = '#6366F1'; // Input -> purple | |
break; | |
case 'researcher': | |
edgeColor = '#4C1D95'; // Researcher -> deep purple | |
break; | |
case 'agent': | |
edgeColor = '#10B981'; // Agent -> green | |
break; | |
case 'insights': | |
edgeColor = '#F59E0B'; // Insights -> amber | |
break; | |
default: | |
edgeColor = '#6366f1'; // Default | |
} | |
} | |
return { | |
...edge, | |
type: 'custom', // Use our custom edge type | |
animated: true, | |
style: { stroke: edgeColor, strokeWidth: 2 }, | |
markerEnd: { | |
type: MarkerType.ArrowClosed, | |
color: edgeColor, | |
width: 20, | |
height: 20, | |
}, | |
data: { ...edge.data, sourceType: sourceNode ? sourceNode.type : 'unknown' } | |
}; | |
}); | |
setEdges(animatedEdges); | |
} | |
} catch (error) { | |
console.error('Error loading workflow:', error); | |
alert('Failed to load workflow'); | |
} | |
}; | |
loadWorkflow(); | |
}, [workflowId]); | |
// Initial load of agents | |
useEffect(() => { | |
loadCustomAgents(); | |
}, []); | |
// Podcast generation functions | |
const handleGeneratePodcast = async () => { | |
if (!podcastText.trim()) { | |
console.log('Please enter some text'); | |
return; | |
} | |
setIsGenerating(true); | |
setSuccessMessage(''); | |
try { | |
const response = await fetch('http://localhost:8000/generate-text-podcast', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${localStorage.getItem('token')}` | |
}, | |
body: JSON.stringify({ | |
text: podcastText, | |
voice_id: selectedVoice, | |
emotion: selectedEmotion, | |
speed: voiceSpeed | |
}) | |
}); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(errorData.detail || 'Failed to generate podcast'); | |
} | |
const data = await response.json(); | |
console.log('Podcast generated:', data); | |
if (data.audio_url) { | |
setAudioUrl(data.audio_url); | |
setSuccessMessage('Podcast generated successfully! You can now play it below.'); | |
} | |
} catch (error) { | |
console.error('Error generating podcast:', error); | |
setSuccessMessage(`Error: ${error.message}`); | |
} finally { | |
setIsGenerating(false); | |
} | |
}; | |
// Audio player functions | |
const handlePlayPause = () => { | |
if (audioRef.current) { | |
if (isPlaying) { | |
audioRef.current.pause(); | |
} else { | |
audioRef.current.play(); | |
} | |
setIsPlaying(!isPlaying); | |
} | |
}; | |
const formatTime = (time) => { | |
const minutes = Math.floor(time / 60); | |
const seconds = Math.floor(time % 60); | |
return `${minutes}:${seconds.toString().padStart(2, '0')}`; | |
}; | |
const handleTimeUpdate = () => { | |
if (audioRef.current) { | |
setCurrentTime(audioRef.current.currentTime); | |
} | |
}; | |
const handleLoadedMetadata = () => { | |
if (audioRef.current) { | |
setDuration(audioRef.current.duration); | |
} | |
}; | |
const handleProgressClick = (e) => { | |
if (audioRef.current) { | |
const progressBar = e.currentTarget; | |
const rect = progressBar.getBoundingClientRect(); | |
const clickPosition = (e.clientX - rect.left) / rect.width; | |
const newTime = clickPosition * audioRef.current.duration; | |
if (!isNaN(newTime)) { | |
audioRef.current.currentTime = newTime; | |
setCurrentTime(newTime); | |
} | |
} | |
}; | |
const handleProgressMouseMove = (e) => { | |
if (audioRef.current && e.buttons === 1) { // Check if primary mouse button is pressed | |
const progressBar = e.currentTarget; | |
const rect = progressBar.getBoundingClientRect(); | |
const clickPosition = (e.clientX - rect.left) / rect.width; | |
const newTime = clickPosition * audioRef.current.duration; | |
if (!isNaN(newTime)) { | |
audioRef.current.currentTime = newTime; | |
setCurrentTime(newTime); | |
} | |
} | |
}; | |
// Edge class assignment based on source node type | |
const getEdgeClassName = useCallback((edge) => { | |
const sourceType = edge.data?.sourceType; | |
if (sourceType) { | |
return `animated source-${sourceType}`; | |
} | |
return 'animated'; | |
}, []); | |
// Apply classes to existing edges | |
useEffect(() => { | |
setEdges((eds) => | |
eds.map(edge => { | |
// Find source node for this edge | |
const sourceNode = nodes.find(n => n.id === edge.source); | |
if (sourceNode && sourceNode.type) { | |
// Update edge data with source type if not already set | |
if (!edge.data || !edge.data.sourceType) { | |
return { | |
...edge, | |
data: { | |
...(edge.data || {}), | |
sourceType: sourceNode.type | |
} | |
}; | |
} | |
} | |
return edge; | |
}) | |
); | |
}, [nodes, setEdges]); | |
// Function to attach edge classes to elements in DOM after they're rendered | |
useEffect(() => { | |
// Apply classes to edge elements based on their source type | |
const edgeElements = document.querySelectorAll('.react-flow__edge'); | |
edgeElements.forEach(el => { | |
const edgeId = el.getAttribute('data-testid')?.split('__').pop(); | |
if (edgeId) { | |
const edge = edges.find(e => e.id === edgeId); | |
if (edge && edge.data && edge.data.sourceType) { | |
el.classList.add(`source-${edge.data.sourceType}`); | |
el.classList.add('animated'); | |
} | |
} | |
}); | |
}, [edges]); | |
// Effect to update nodes with execution state classes | |
useEffect(() => { | |
if (Object.keys(executionState).length === 0) return; | |
setNodes(nds => nds.map(node => { | |
if (executionState[node.id]) { | |
const status = executionState[node.id].status; | |
return { | |
...node, | |
className: `${node.className || ''} ${status}`.trim() | |
}; | |
} | |
return node; | |
})); | |
}, [executionState, setNodes]); | |
// OnInit callback | |
const onInit = useCallback((reactFlowInstance) => { | |
setRfInstance(reactFlowInstance); | |
// Set initial viewport with proper zoom | |
reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 0.8 }); | |
// Center view on nodes if they exist | |
if (nodes.length > 0) { | |
setTimeout(() => { | |
reactFlowInstance.fitView({ padding: 0.2, includeHiddenNodes: false }); | |
}, 100); | |
} | |
}, [nodes]); | |
return ( | |
<div className="editor-container"> | |
<div className="editor-header"> | |
<h1>Build out a workflow for your podcast generation <TiFlowMerge /></h1> | |
<button className="back-button" onClick={() => navigate('/workflows')}> | |
<FaArrowLeft /> Back to Workflows | |
</button> | |
</div> | |
<div className="editor-content"> | |
<div className="editor-main"> | |
<div className="flow-controls"> | |
<div className="workflow-name-container"> | |
{isEditingName ? ( | |
<> | |
<input | |
type="text" | |
value={tempWorkflowName} | |
onChange={(e) => setTempWorkflowName(e.target.value)} | |
placeholder="Enter workflow name..." | |
className="workflow-name-input" | |
/> | |
<button | |
className="name-action-button save" | |
onClick={handleSaveName} | |
title="Save name" | |
> | |
<FaCheck /> | |
</button> | |
<button | |
className="name-action-button cancel" | |
onClick={handleCancelNameEdit} | |
title="Cancel" | |
> | |
<FaTimes /> | |
</button> | |
</> | |
) : ( | |
<> | |
<div onClick={handleEditName} className="workflow-name-display"> | |
{workflowName || 'Untitled Workflow'} | |
</div> | |
<button | |
className="name-action-button edit" | |
onClick={handleEditName} | |
title="Edit name" | |
> | |
<FaPencilAlt /> | |
</button> | |
</> | |
)} | |
</div> | |
<button className="flow-button save-workflow" onClick={handleSaveWorkflow}> | |
<FaSave /> | |
<span>Save</span> | |
</button> | |
<button className="flow-button clear-workflow" onClick={handleClearWorkflow}> | |
<FaTrash /> | |
<span>Clear</span> | |
</button> | |
<button className={`flow-button execute-workflow ${isExecuting ? 'executing' : ''}`} onClick={handleExecuteWorkflow} disabled={isExecuting}> | |
{isExecuting ? ( | |
<> | |
<div className="button-spinner"></div> | |
<span>Running</span> | |
</> | |
) : ( | |
<> | |
<FaPlay /> | |
<span>Run</span> | |
</> | |
)} | |
</button> | |
<button className="flow-button add-node" onClick={handleAddNode}> | |
<FaPlus /> | |
<span>Add</span> | |
</button> | |
</div> | |
<div className="flow-wrapper"> | |
<ReactFlow | |
nodes={nodes} | |
edges={edges} | |
onNodesChange={onNodesChange} | |
onEdgesChange={onEdgesChange} | |
onConnect={onConnect} | |
nodeTypes={customNodeTypes} | |
defaultEdgeOptions={defaultEdgeOptions} | |
edgesFocusable={true} | |
edgesUpdatable={true} | |
elementsSelectable={true} | |
onInit={onInit} | |
defaultViewport={{ x: 0, y: 0, zoom: 0.8 }} | |
attributionPosition="bottom-left" | |
className="workflow-reactflow" | |
deleteKeyCode={['Backspace', 'Delete']} | |
edgeTypes={edgeTypes} | |
maxZoom={2} | |
minZoom={0.2} | |
proOptions={{ hideAttribution: true }} | |
onNodeClick={onNodeClick} | |
> | |
<Controls /> | |
<Background variant={BackgroundVariant.Dots} gap={12} size={1} /> | |
<MiniMap /> | |
</ReactFlow> | |
</div> | |
</div> | |
<div className="editor-insights"> | |
<div | |
className="toggle-insights" | |
onClick={() => setIsInsightsEnabled(!isInsightsEnabled)} | |
style={{ cursor: 'pointer' }} | |
title={isInsightsEnabled ? "Click to disable insights" : "Click to enable insights"} | |
> | |
<h2>{isInsightsEnabled ? "Workflow Insights" : "Text to Podcast"}</h2> | |
{isInsightsEnabled ? <BsToggle2On style={{ marginLeft: '10px' }} /> : <BsToggle2Off style={{ marginLeft: '10px' }} />} | |
</div> | |
<div className="insights-content"> | |
{isExecuting ? ( | |
<div className="loading-insights"> | |
<p>Executing workflow... Please wait.</p> | |
<div className="execution-loader"></div> | |
</div> | |
) : workflowInsights ? ( | |
<div className="insights-result"> | |
<pre>{workflowInsights}</pre> | |
</div> | |
) : isInsightsEnabled ? ( | |
<p> | |
Workflow insights are now active. You'll see real-time analytics and insights about your workflow here. | |
</p> | |
) : ( | |
<div className="podcast-generation-form"> | |
<div className="voice-controls"> | |
<div className="voice-select-group"> | |
<label>Voice:</label> | |
<select | |
value={selectedVoice} | |
onChange={(e) => setSelectedVoice(e.target.value)} | |
> | |
{voices.map(voice => ( | |
<option key={voice.id} value={voice.id}> | |
{voice.name} | |
</option> | |
))} | |
</select> | |
</div> | |
<div className="voice-select-group"> | |
<label>Emotion:</label> | |
<select | |
value={selectedEmotion} | |
onChange={(e) => setSelectedEmotion(e.target.value)} | |
> | |
{emotions.map(emotion => ( | |
<option key={emotion.id} value={emotion.id}> | |
{emotion.name} | |
</option> | |
))} | |
</select> | |
</div> | |
<div className="voice-select-group"> | |
<label>Speed: {voiceSpeed}x</label> | |
<input | |
type="range" | |
min="0.5" | |
max="2.0" | |
step="0.1" | |
value={voiceSpeed} | |
onChange={(e) => setVoiceSpeed(parseFloat(e.target.value))} | |
className="speed-slider" | |
/> | |
</div> | |
</div> | |
<textarea | |
value={podcastText} | |
onChange={(e) => setPodcastText(e.target.value)} | |
placeholder="Enter your text here to generate a podcast..." | |
rows={8} | |
className="podcast-text-input" | |
/> | |
<button | |
className="generate-button" | |
onClick={handleGeneratePodcast} | |
disabled={isGenerating || !podcastText.trim()} | |
> | |
{isGenerating ? 'Generating...' : 'Generate Podcast'} | |
</button> | |
{successMessage && ( | |
<div className={`generation-message ${successMessage.includes('Error') ? 'error' : 'success'}`}> | |
{successMessage} | |
</div> | |
)} | |
</div> | |
)} | |
</div> | |
</div> | |
<div className="editor-sidebar"> | |
<div className="sidebar-card agents-view"> | |
<h3><RiRobot2Fill /> Create your agents</h3> | |
<div className="agents-list"> | |
{isLoadingAgents ? ( | |
<div className="loading-agents">Loading agents...</div> | |
) : ( | |
agents.map((agent) => ( | |
<div | |
key={agent.id} | |
className={`agent-item ${agent.isDefault ? 'default' : 'custom'}`} | |
onClick={() => handleAgentClick(agent)} | |
style={{ cursor: agent.isDefault ? 'default' : 'pointer' }} | |
> | |
<span className="agent-name">{agent.name}</span> | |
<span className="agent-status">{agent.status}</span> | |
</div> | |
)) | |
)} | |
</div> | |
<button | |
className="create-agents-btn" | |
onClick={() => setIsAgentModalOpen(true)} | |
type="button" | |
> | |
<FaUserCog /> Create your own agents | |
</button> | |
<p>Design and configure AI agents with unique personalities and roles for your podcast</p> | |
</div> | |
<div className="sidebar-card podcast-audio-view"> | |
<h3><FaVolumeUp /> Podcast Preview</h3> | |
<div className="audio-player"> | |
<audio | |
ref={audioRef} | |
src={audioUrl} | |
onTimeUpdate={handleTimeUpdate} | |
onLoadedMetadata={handleLoadedMetadata} | |
onEnded={() => setIsPlaying(false)} | |
onPause={() => setIsPlaying(false)} | |
onPlay={() => setIsPlaying(true)} | |
/> | |
<div className="player-controls"> | |
<button | |
className="play-button" | |
onClick={handlePlayPause} | |
disabled={!audioUrl} | |
> | |
{isPlaying ? <FaPause /> : <FaPlay />} | |
</button> | |
<div | |
className="progress-bar" | |
onClick={handleProgressClick} | |
onMouseMove={handleProgressMouseMove} | |
> | |
<div | |
className="progress" | |
style={{ | |
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` | |
}} | |
/> | |
</div> | |
<div className="time-display"> | |
{formatTime(currentTime)} / {formatTime(duration)} | |
</div> | |
</div> | |
</div> | |
<p>Preview your generated podcast audio</p> | |
</div> | |
</div> | |
</div> | |
<NodeSelectionPanel | |
isOpen={isNodePanelOpen} | |
onClose={() => setIsNodePanelOpen(false)} | |
agents={agents} | |
onSelectNode={handleNodeSelect} | |
/> | |
<InputNodeModal | |
isOpen={isInputModalOpen} | |
onClose={() => setIsInputModalOpen(false)} | |
onSubmit={handleInputSubmit} | |
nodeId={selectedInputNode} | |
/> | |
<AgentModal | |
isOpen={isAgentModalOpen} | |
onClose={handleAgentModalClose} | |
editAgent={selectedAgent} | |
/> | |
{toast.message && ( | |
<div className="workflow-toast-container"> | |
<WorkflowToast | |
message={toast.message} | |
type={toast.type} | |
onClose={() => setToast(initialToastState)} | |
/> | |
</div> | |
)} | |
</div> | |
); | |
}; | |
export default WorkflowEditor; |