'use client'
import React, { useState, useEffect } from 'react'
import { Plus, Trash2, ChevronUp, ChevronDown, Edit2, X, Save, Loader2, Bug } from 'lucide-react'
import { WorkflowStep, WorkflowVariable } from '../services/workflowService'
import mcpService from '../services/mcpService'
interface WorkflowStepEditorProps {
steps: WorkflowStep[]
variables: WorkflowVariable[]
onChange: (steps: WorkflowStep[]) => void
}
export default function WorkflowStepEditor({ steps, variables, onChange }: WorkflowStepEditorProps) {
const [editingStep, setEditingStep] = useState<string | null>(null)
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set())
const addStep = () => {
const newStep: WorkflowStep = {
id: `step_${Date.now()}`,
order: steps.length + 1,
name: 'New Step',
type: 'mcp_tool',
required: true,
mcp_server: '',
tool_name: '',
arguments: {},
breakpoint: false
}
onChange([...steps, newStep])
setEditingStep(newStep.id)
setExpandedSteps(new Set([...expandedSteps, newStep.id]))
}
const removeStep = (stepId: string) => {
const newSteps = steps.filter(s => s.id !== stepId).map((s, idx) => ({ ...s, order: idx + 1 }))
onChange(newSteps)
setEditingStep(null)
expandedSteps.delete(stepId)
setExpandedSteps(new Set(expandedSteps))
}
const moveStep = (stepId: string, direction: 'up' | 'down') => {
const index = steps.findIndex(s => s.id === stepId)
if (index === -1) return
if (direction === 'up' && index === 0) return
if (direction === 'down' && index === steps.length - 1) return
const newSteps = [...steps]
const newIndex = direction === 'up' ? index - 1 : index + 1
;[newSteps[index], newSteps[newIndex]] = [newSteps[newIndex], newSteps[index]]
newSteps.forEach((s, idx) => { s.order = idx + 1 })
onChange(newSteps)
}
const updateStep = (stepId: string, updates: Partial<WorkflowStep>) => {
const newSteps = steps.map(s => s.id === stepId ? { ...s, ...updates } : s)
onChange(newSteps)
}
const toggleExpand = (stepId: string) => {
const newExpanded = new Set(expandedSteps)
if (newExpanded.has(stepId)) {
newExpanded.delete(stepId)
if (editingStep === stepId) setEditingStep(null)
} else {
newExpanded.add(stepId)
}
setExpandedSteps(newExpanded)
}
const renderStepEditor = (step: WorkflowStep) => {
const isExpanded = expandedSteps.has(step.id)
const isEditing = editingStep === step.id
return (
<div
key={step.id}
style={{
border: '1px solid #e5e7eb',
borderRadius: '8px',
marginBottom: '12px',
backgroundColor: 'white'
}}
>
{/* Step Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
cursor: 'pointer',
backgroundColor: isExpanded ? '#f9fafb' : 'white',
borderBottom: isExpanded ? '1px solid #e5e7eb' : 'none'
}}
onClick={() => toggleExpand(step.id)}
>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: '#3b82f6',
color: 'white',
fontSize: '12px',
fontWeight: '600'
}}>
{step.order}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#111827' }}>
{step.name || 'Unnamed Step'}
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
{step.type} {step.mcp_server && step.tool_name && `• ${step.mcp_server}/${step.tool_name}`}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); moveStep(step.id, 'up') }}
disabled={step.order === 1}
style={{
padding: '4px',
border: 'none',
backgroundColor: 'transparent',
cursor: step.order === 1 ? 'not-allowed' : 'pointer',
opacity: step.order === 1 ? 0.3 : 1
}}
>
<ChevronUp style={{ width: '16px', height: '16px', color: '#6b7280' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); moveStep(step.id, 'down') }}
disabled={step.order === steps.length}
style={{
padding: '4px',
border: 'none',
backgroundColor: 'transparent',
cursor: step.order === steps.length ? 'not-allowed' : 'pointer',
opacity: step.order === steps.length ? 0.3 : 1
}}
>
<ChevronDown style={{ width: '16px', height: '16px', color: '#6b7280' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setEditingStep(isEditing ? null : step.id) }}
style={{
padding: '4px',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer'
}}
>
<Edit2 style={{ width: '16px', height: '16px', color: '#6b7280' }} />
</button>
<button
onClick={(e) => { e.stopPropagation(); removeStep(step.id) }}
style={{
padding: '4px',
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer'
}}
>
<Trash2 style={{ width: '16px', height: '16px', color: '#ef4444' }} />
</button>
</div>
</div>
{/* Step Editor */}
{isExpanded && (
<div style={{ padding: '16px' }}>
{isEditing ? (
<StepForm
step={step}
variables={variables}
onSave={(updates) => {
updateStep(step.id, updates)
setEditingStep(null)
}}
onCancel={() => setEditingStep(null)}
/>
) : (
<StepView step={step} />
)}
</div>
)}
</div>
)
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<h3 style={{ fontSize: '18px', fontWeight: '600', color: '#111827' }}>
Workflow Steps ({steps.length})
</h3>
<button
onClick={addStep}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
<Plus style={{ width: '16px', height: '16px' }} />
Add Step
</button>
</div>
{steps.length === 0 ? (
<div style={{
padding: '48px',
textAlign: 'center',
backgroundColor: 'white',
border: '2px dashed #e5e7eb',
borderRadius: '8px'
}}>
<p style={{ color: '#6b7280', marginBottom: '16px' }}>No steps yet. Add your first step to get started.</p>
<button
onClick={addStep}
style={{
padding: '10px 20px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
Add First Step
</button>
</div>
) : (
<div>
{steps.sort((a, b) => a.order - b.order).map(renderStepEditor)}
</div>
)}
</div>
)
}
function StepForm({ step, variables, onSave, onCancel }: {
step: WorkflowStep
variables: WorkflowVariable[]
onSave: (updates: Partial<WorkflowStep>) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<Partial<WorkflowStep>>({
name: step.name,
type: step.type,
mcp_server: step.mcp_server || '',
tool_name: step.tool_name || '',
app_id: step.app_id || '',
arguments: step.arguments || {},
timeout: step.timeout,
required: step.required ?? true,
output_variable: step.output_variable || '',
...(step.app_config && { app_config: step.app_config })
})
const handleSave = () => {
onSave(formData)
}
const updateField = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
// Load MCP servers on mount
useEffect(() => {
loadMCPServers()
}, [])
// Load tools when MCP server changes
useEffect(() => {
if (formData.type === 'mcp_tool' && formData.mcp_server) {
loadMCPTools(formData.mcp_server)
} else {
setAvailableTools([])
setSelectedTool(null)
}
}, [formData.mcp_server, formData.type])
// Update selected tool when tool_name changes
useEffect(() => {
if (formData.tool_name && availableTools.length > 0) {
const tool = availableTools.find(t => t.name === formData.tool_name)
setSelectedTool(tool || null)
} else {
setSelectedTool(null)
}
}, [formData.tool_name, availableTools])
const loadMCPServers = async () => {
setLoadingServers(true)
try {
const servers = await mcpService.getServers()
const serverNames = Object.keys(servers).filter(name => servers[name].enabled && servers[name].healthy)
setMcpServers(serverNames)
} catch (error) {
console.error('Failed to load MCP servers:', error)
} finally {
setLoadingServers(false)
}
}
const loadMCPTools = async (serverName: string) => {
setLoadingTools(true)
try {
const tools = await mcpService.listTools(serverName)
setAvailableTools(tools || [])
// If tool_name is already set, find and select it
if (formData.tool_name) {
const tool = tools.find((t: MCPTool) => t.name === formData.tool_name)
if (tool) setSelectedTool(tool)
}
} catch (error) {
console.error(`Failed to load tools for ${serverName}:`, error)
setAvailableTools([])
} finally {
setLoadingTools(false)
}
}
const updateArguments = (key: string, value: any) => {
setFormData(prev => ({
...prev,
arguments: { ...(prev.arguments || {}), [key]: value }
}))
}
const renderArgumentField = (paramName: string, paramSchema: any) => {
const paramType = paramSchema.type || 'string'
const paramValue = formData.arguments?.[paramName] ?? paramSchema.default ?? ''
const isRequired = selectedTool?.inputSchema?.required?.includes(paramName) || false
if (paramType === 'boolean') {
return (
<div key={paramName} style={{ marginBottom: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#374151' }}>
<input
type="checkbox"
checked={paramValue === true || paramValue === 'true'}
onChange={(e) => updateArguments(paramName, e.target.checked)}
/>
<span>
{paramName} {isRequired && <span style={{ color: '#ef4444' }}>*</span>}
</span>
</label>
{paramSchema.description && (
<p style={{ fontSize: '11px', color: '#6b7280', marginTop: '4px', marginLeft: '24px' }}>
{paramSchema.description}
</p>
)}
</div>
)
}
if (paramType === 'number' || paramType === 'integer') {
return (
<div key={paramName} style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
{paramName} {isRequired && <span style={{ color: '#ef4444' }}>*</span>}
{paramSchema.description && (
<span style={{ fontSize: '11px', color: '#6b7280', fontWeight: 'normal', marginLeft: '8px' }}>
- {paramSchema.description}
</span>
)}
</label>
<input
type="number"
value={paramValue}
onChange={(e) => updateArguments(paramName, paramType === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value))}
placeholder={paramSchema.default?.toString() || `Enter ${paramName}`}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
)
}
// String or object/array (as JSON)
return (
<div key={paramName} style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
{paramName} {isRequired && <span style={{ color: '#ef4444' }}>*</span>}
{paramSchema.description && (
<span style={{ fontSize: '11px', color: '#6b7280', fontWeight: 'normal', marginLeft: '8px' }}>
- {paramSchema.description}
</span>
)}
</label>
{paramType === 'object' || paramType === 'array' ? (
<textarea
value={typeof paramValue === 'string' ? paramValue : JSON.stringify(paramValue, null, 2)}
onChange={(e) => {
try {
updateArguments(paramName, JSON.parse(e.target.value))
} catch {
updateArguments(paramName, e.target.value)
}
}}
placeholder={JSON.stringify(paramSchema.default || {}, null, 2)}
rows={4}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '12px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
) : (
<input
type="text"
value={typeof paramValue === 'string' ? paramValue : JSON.stringify(paramValue)}
onChange={(e) => updateArguments(paramName, e.target.value)}
placeholder={paramSchema.default?.toString() || `Enter ${paramName}`}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
)}
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Basic Fields */}
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Step Name *
</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => updateField('name', e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Step Type *
</label>
<select
value={formData.type || 'mcp_tool'}
onChange={(e) => updateField('type', e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="mcp_tool">MCP Tool</option>
<option value="app_launch">App Launch</option>
<option value="delay">Delay</option>
<option value="condition">Condition</option>
<option value="user_input">User Input</option>
</select>
</div>
{/* MCP Tool Fields */}
{formData.type === 'mcp_tool' && (
<>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
MCP Server *
</label>
{loadingServers ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 12px', color: '#6b7280' }}>
<Loader2 style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: '14px' }}>Loading servers...</span>
</div>
) : (
<select
value={formData.mcp_server || ''}
onChange={(e) => {
updateField('mcp_server', e.target.value)
updateField('tool_name', '') // Clear tool when server changes
updateField('arguments', {}) // Clear arguments
}}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="">Select MCP Server...</option>
{mcpServers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
)}
</div>
{formData.mcp_server && (
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Tool *
</label>
{loadingTools ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 12px', color: '#6b7280' }}>
<Loader2 style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: '14px' }}>Loading tools...</span>
</div>
) : availableTools.length === 0 ? (
<div style={{ padding: '8px 12px', backgroundColor: '#fef3c7', border: '1px solid #fbbf24', borderRadius: '6px', fontSize: '12px', color: '#92400e' }}>
No tools available for this server
</div>
) : (
<>
<select
value={formData.tool_name || ''}
onChange={(e) => {
updateField('tool_name', e.target.value)
updateField('arguments', {}) // Reset arguments when tool changes
}}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="">Select Tool...</option>
{availableTools.map((tool: MCPTool) => (
<option key={tool.name} value={tool.name}>
{tool.name} {tool.description && `- ${tool.description.substring(0, 50)}`}
</option>
))}
</select>
{selectedTool?.description && (
<div style={{
marginTop: '6px',
padding: '8px 12px',
backgroundColor: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: '6px',
fontSize: '12px',
color: '#0c4a6e'
}}>
<strong>Description:</strong> {selectedTool.description}
</div>
)}
</>
)}
</div>
)}
{/* Tool Arguments - Dynamic Form Based on Schema */}
{selectedTool && selectedTool.inputSchema?.properties && Object.keys(selectedTool.inputSchema.properties).length > 0 && (
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#374151' }}>
Tool Arguments
</label>
<div style={{
padding: '12px',
backgroundColor: '#f9fafb',
borderRadius: '6px',
border: '1px solid #e5e7eb'
}}>
{Object.entries(selectedTool.inputSchema.properties).map(([paramName, paramSchema]: [string, any]) =>
renderArgumentField(paramName, paramSchema)
)}
</div>
</div>
)}
{/* Fallback JSON Editor if no schema */}
{formData.tool_name && (!selectedTool || !selectedTool.inputSchema?.properties || Object.keys(selectedTool.inputSchema.properties).length === 0) && (
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Arguments (JSON)
</label>
<textarea
value={JSON.stringify(formData.arguments || {}, null, 2)}
onChange={(e) => {
try {
updateField('arguments', JSON.parse(e.target.value))
} catch {}
}}
placeholder='{"key": "value"}'
rows={4}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '12px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
</div>
)}
</>
)}
{/* App Launch Fields */}
{formData.type === 'app_launch' && (
<>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
App ID
</label>
<select
value={formData.app_id || ''}
onChange={(e) => updateField('app_id', e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="">Select app...</option>
<option value="unity3d">Unity3D</option>
<option value="resonite">Resonite</option>
<option value="vrchat">VRChat</option>
<option value="vroid">VRoid Studio</option>
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Desktop
</label>
<input
type="number"
value={formData.app_config?.desktop || ''}
onChange={(e) => updateField('app_config', { ...formData.app_config, desktop: parseInt(e.target.value) || undefined })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Monitor
</label>
<input
type="number"
value={formData.app_config?.monitor || ''}
onChange={(e) => updateField('app_config', { ...formData.app_config, monitor: parseInt(e.target.value) || undefined })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#374151' }}>
<input
type="checkbox"
checked={formData.app_config?.fullscreen || false}
onChange={(e) => updateField('app_config', { ...formData.app_config, fullscreen: e.target.checked })}
/>
Fullscreen
</label>
</div>
</>
)}
{/* Delay Fields */}
{formData.type === 'delay' && (
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Delay (seconds)
</label>
<input
type="number"
value={formData.arguments?.delay || ''}
onChange={(e) => updateField('arguments', { delay: parseFloat(e.target.value) || 0 })}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
)}
{/* Common Fields */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Timeout (seconds)
</label>
<input
type="number"
value={formData.timeout || ''}
onChange={(e) => updateField('timeout', e.target.value ? parseInt(e.target.value) : undefined)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '6px', color: '#374151' }}>
Output Variable
</label>
<select
value={formData.output_variable || ''}
onChange={(e) => updateField('output_variable', e.target.value || undefined)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
fontSize: '14px',
backgroundColor: 'white'
}}
>
<option value="">None</option>
{variables.map(v => (
<option key={v.name} value={v.name}>{v.name}</option>
))}
</select>
</div>
</div>
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#374151' }}>
<input
type="checkbox"
checked={formData.required ?? true}
onChange={(e) => updateField('required', e.target.checked)}
/>
Required Step
</label>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '8px' }}>
<button
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: '#f3f4f6',
color: '#374151',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!formData.name?.trim()}
style={{
padding: '8px 16px',
backgroundColor: !formData.name?.trim() ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: !formData.name?.trim() ? 'not-allowed' : 'pointer'
}}
>
Save Step
</button>
</div>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
function StepView({ step }: { step: WorkflowStep }) {
return (
<div style={{ fontSize: '13px', color: '#6b7280' }}>
<div style={{ marginBottom: '8px' }}>
<strong>Type:</strong> {step.type}
</div>
{step.mcp_server && step.tool_name && (
<div style={{ marginBottom: '8px' }}>
<strong>Tool:</strong> {step.mcp_server}/{step.tool_name}
</div>
)}
{step.app_id && (
<div style={{ marginBottom: '8px' }}>
<strong>App:</strong> {step.app_id}
</div>
)}
{step.timeout && (
<div style={{ marginBottom: '8px' }}>
<strong>Timeout:</strong> {step.timeout}s
</div>
)}
{step.output_variable && (
<div>
<strong>Output:</strong> {step.output_variable}
</div>
)}
</div>
)
}