Initial yaml

This commit is contained in:
Siddharth Ganesan
2025-07-08 20:54:15 -07:00
parent 6cb15a620a
commit 2a0224f6ae
3 changed files with 848 additions and 0 deletions

View File

@@ -0,0 +1,335 @@
'use client'
import { useState, useRef } from 'react'
import { Upload, FileText, Plus, AlertCircle, CheckCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { importWorkflowFromYaml, parseWorkflowYaml } from '@/stores/workflows/yaml/importer'
import { useRouter } from 'next/navigation'
import { useParams } from 'next/navigation'
const logger = createLogger('ImportControls')
interface ImportControlsProps {
disabled?: boolean
}
export function ImportControls({ disabled = false }: ImportControlsProps) {
const [isImporting, setIsImporting] = useState(false)
const [showYamlDialog, setShowYamlDialog] = useState(false)
const [yamlContent, setYamlContent] = useState('')
const [importResult, setImportResult] = useState<{
success: boolean
errors: string[]
warnings: string[]
summary?: string
} | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const params = useParams()
const workspaceId = params.workspaceId as string
// Stores and hooks
const { createWorkflow } = useWorkflowRegistry()
const { collaborativeAddBlock, collaborativeAddEdge } = useCollaborativeWorkflow()
const subBlockStore = useSubBlockStore()
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const content = await file.text()
setYamlContent(content)
setShowYamlDialog(true)
} catch (error) {
logger.error('Failed to read file:', error)
setImportResult({
success: false,
errors: [`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`],
warnings: []
})
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleYamlImport = async () => {
if (!yamlContent.trim()) {
setImportResult({
success: false,
errors: ['YAML content is required'],
warnings: []
})
return
}
setIsImporting(true)
setImportResult(null)
try {
// First validate the YAML without importing
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
if (!yamlWorkflow || parseErrors.length > 0) {
setImportResult({
success: false,
errors: parseErrors,
warnings: []
})
return
}
// Create a new workflow
logger.info('Creating new workflow for YAML import')
const newWorkflowId = await createWorkflow({
name: `Imported Workflow - ${new Date().toLocaleString()}`,
description: 'Workflow imported from YAML',
workspaceId,
})
// Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
// Small delay to ensure navigation and workflow initialization
await new Promise(resolve => setTimeout(resolve, 1000))
// Import the YAML into the new workflow
const result = await importWorkflowFromYaml(yamlContent, {
addBlock: collaborativeAddBlock,
addEdge: collaborativeAddEdge,
applyAutoLayout: () => {
// Trigger auto layout
window.dispatchEvent(new CustomEvent('trigger-auto-layout'))
},
setSubBlockValue: (blockId: string, subBlockId: string, value: any) => {
subBlockStore.setValue(blockId, subBlockId, value)
},
getExistingBlocks: () => {
// This will be called after navigation, so we need to get blocks from the store
const { useWorkflowStore } = require('@/stores/workflows/workflow/store')
return useWorkflowStore.getState().blocks
}
})
setImportResult(result)
if (result.success) {
// Close dialog on success
setTimeout(() => {
setShowYamlDialog(false)
setYamlContent('')
setImportResult(null)
}, 2000)
}
} catch (error) {
logger.error('Failed to import YAML workflow:', error)
setImportResult({
success: false,
errors: [`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`],
warnings: []
})
} finally {
setIsImporting(false)
}
}
const handleOpenYamlDialog = () => {
setYamlContent('')
setImportResult(null)
setShowYamlDialog(true)
}
const isDisabled = disabled || isImporting
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-10 w-10 cursor-not-allowed items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm opacity-50 ring-offset-background transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'>
<Plus className='h-4 w-4' />
</div>
) : (
<Button
variant='ghost'
size='icon'
className='hover:text-primary'
disabled={isDisabled}
>
<Plus className='h-4 w-4' />
<span className='sr-only'>Import Workflow</span>
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-64'>
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isDisabled}
className='flex cursor-pointer items-center gap-2'
>
<Upload className='h-4 w-4' />
<div className='flex flex-col'>
<span>Upload YAML File</span>
<span className='text-muted-foreground text-xs'>Import from .yaml or .yml file</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleOpenYamlDialog}
disabled={isDisabled}
className='flex cursor-pointer items-center gap-2'
>
<FileText className='h-4 w-4' />
<div className='flex flex-col'>
<span>Paste YAML</span>
<span className='text-muted-foreground text-xs'>Import from YAML text</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
{isDisabled
? isImporting
? 'Importing workflow...'
: 'Cannot import workflow'
: 'Import Workflow from YAML'}
</TooltipContent>
</Tooltip>
{/* Hidden file input */}
<input
ref={fileInputRef}
type='file'
accept='.yaml,.yml'
onChange={handleFileUpload}
className='hidden'
/>
{/* YAML Import Dialog */}
<Dialog open={showYamlDialog} onOpenChange={setShowYamlDialog}>
<DialogContent className='max-w-4xl max-h-[80vh] flex flex-col'>
<DialogHeader>
<DialogTitle>Import Workflow from YAML</DialogTitle>
<DialogDescription>
Paste your workflow YAML content below. This will create a new workflow with the blocks and connections defined in the YAML.
</DialogDescription>
</DialogHeader>
<div className='flex-1 space-y-4 overflow-hidden'>
<Textarea
placeholder={`version: "1.0"
blocks:
start:
type: "starter"
name: "Start"
inputs:
startWorkflow: "manual"
following:
- "process"
process:
type: "agent"
name: "Process Data"
inputs:
systemPrompt: "You are a helpful assistant"
userPrompt: "Process the data"
model: "gpt-4"
preceding:
- "start"`}
value={yamlContent}
onChange={(e) => setYamlContent(e.target.value)}
className='min-h-[300px] font-mono text-sm'
disabled={isImporting}
/>
{/* Import Result */}
{importResult && (
<div className='space-y-2'>
{importResult.success ? (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium text-green-700'>Import Successful!</div>
{importResult.summary && (
<div className='mt-1 text-sm'>{importResult.summary}</div>
)}
{importResult.warnings.length > 0 && (
<div className='mt-2'>
<div className='text-sm font-medium'>Warnings:</div>
<ul className='mt-1 space-y-1 text-sm'>
{importResult.warnings.map((warning, index) => (
<li key={index} className='text-yellow-700'> {warning}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
) : (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<div className='font-medium'>Import Failed</div>
{importResult.errors.length > 0 && (
<ul className='mt-2 space-y-1 text-sm'>
{importResult.errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowYamlDialog(false)}
disabled={isImporting}
>
Cancel
</Button>
<Button
onClick={handleYamlImport}
disabled={isImporting || !yamlContent.trim()}
>
{isImporting ? 'Importing...' : 'Import Workflow'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -57,6 +57,7 @@ import {
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
import { ExportControls } from './components/export-controls/export-controls'
import { ImportControls } from './components/import-controls/import-controls'
import { HistoryDropdownItem } from './components/history-dropdown-item/history-dropdown-item'
import { MarketplaceModal } from './components/marketplace-modal/marketplace-modal'
import { NotificationDropdownItem } from './components/notification-dropdown-item/notification-dropdown-item'
@@ -1288,6 +1289,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderDuplicateButton()}
{renderAutoLayoutButton()}
{renderDebugModeToggle()}
<ImportControls disabled={!userPermissions.canEdit} />
<ExportControls disabled={!userPermissions.canRead} />
{/* {renderPublishButton()} */}
{renderDeployButton()}

View File

@@ -0,0 +1,511 @@
import { load as yamlParse } from 'js-yaml'
import { createLogger } from '@/lib/logs/console-logger'
import { getBlock } from '@/blocks'
import type { BlockConfig } from '@/blocks/types'
const logger = createLogger('WorkflowYamlImporter')
interface YamlBlock {
type: string
name: string
inputs?: Record<string, any>
preceding?: string[]
following?: string[]
}
interface YamlWorkflow {
version: string
blocks: Record<string, YamlBlock>
}
interface ImportedBlock {
id: string
type: string
name: string
inputs: Record<string, any>
position: { x: number; y: number }
data?: Record<string, any>
parentId?: string
extent?: 'parent'
}
interface ImportedEdge {
id: string
source: string
target: string
sourceHandle: string
targetHandle: string
type: string
}
interface ImportResult {
blocks: ImportedBlock[]
edges: ImportedEdge[]
errors: string[]
warnings: string[]
}
/**
* Parse YAML content and validate its structure
*/
export function parseWorkflowYaml(yamlContent: string): { data: YamlWorkflow | null; errors: string[] } {
const errors: string[] = []
try {
const data = yamlParse(yamlContent) as any
// Validate top-level structure
if (!data || typeof data !== 'object') {
errors.push('Invalid YAML: Root must be an object')
return { data: null, errors }
}
if (!data.version) {
errors.push('Missing required field: version')
}
if (!data.blocks || typeof data.blocks !== 'object') {
errors.push('Missing or invalid field: blocks')
return { data: null, errors }
}
// Validate blocks structure
Object.entries(data.blocks).forEach(([blockId, block]: [string, any]) => {
if (!block || typeof block !== 'object') {
errors.push(`Invalid block definition for '${blockId}': must be an object`)
return
}
if (!block.type || typeof block.type !== 'string') {
errors.push(`Invalid block '${blockId}': missing or invalid 'type' field`)
}
if (!block.name || typeof block.name !== 'string') {
errors.push(`Invalid block '${blockId}': missing or invalid 'name' field`)
}
if (block.inputs && typeof block.inputs !== 'object') {
errors.push(`Invalid block '${blockId}': 'inputs' must be an object`)
}
if (block.preceding && !Array.isArray(block.preceding)) {
errors.push(`Invalid block '${blockId}': 'preceding' must be an array`)
}
if (block.following && !Array.isArray(block.following)) {
errors.push(`Invalid block '${blockId}': 'following' must be an array`)
}
})
if (errors.length > 0) {
return { data: null, errors }
}
return { data: data as YamlWorkflow, errors: [] }
} catch (error) {
errors.push(`YAML parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`)
return { data: null, errors }
}
}
/**
* Validate that block references in connections exist
*/
function validateBlockReferences(yamlWorkflow: YamlWorkflow): string[] {
const errors: string[] = []
const blockIds = new Set(Object.keys(yamlWorkflow.blocks))
Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => {
// Check preceding references
if (block.preceding) {
block.preceding.forEach(precedingId => {
if (!blockIds.has(precedingId)) {
errors.push(`Block '${blockId}' references non-existent preceding block '${precedingId}'`)
}
})
}
// Check following references
if (block.following) {
block.following.forEach(followingId => {
if (!blockIds.has(followingId)) {
errors.push(`Block '${blockId}' references non-existent following block '${followingId}'`)
}
})
}
})
return errors
}
/**
* Validate that block types exist and are valid
*/
function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[], warnings: string[] } {
const errors: string[] = []
const warnings: string[] = []
Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => {
// Check if block type exists
const blockConfig = getBlock(block.type)
// Special handling for container blocks
if (block.type === 'loop' || block.type === 'parallel') {
// These are valid container types
return
}
if (!blockConfig) {
errors.push(`Unknown block type '${block.type}' for block '${blockId}'`)
return
}
// Validate inputs against block configuration
if (block.inputs && blockConfig.subBlocks) {
Object.keys(block.inputs).forEach(inputKey => {
const subBlockConfig = blockConfig.subBlocks.find(sb => sb.id === inputKey)
if (!subBlockConfig) {
warnings.push(`Block '${blockId}' has unknown input '${inputKey}' for type '${block.type}'`)
}
})
}
})
return { errors, warnings }
}
/**
* Calculate positions for blocks based on their connections
* Uses a simple layered approach similar to the auto-layout algorithm
*/
function calculateBlockPositions(yamlWorkflow: YamlWorkflow): Record<string, { x: number; y: number }> {
const positions: Record<string, { x: number; y: number }> = {}
const blockIds = Object.keys(yamlWorkflow.blocks)
// Find starter blocks (no preceding connections)
const starterBlocks = blockIds.filter(id => {
const block = yamlWorkflow.blocks[id]
return !block.preceding || block.preceding.length === 0
})
// If no starter blocks found, use first block as starter
if (starterBlocks.length === 0 && blockIds.length > 0) {
starterBlocks.push(blockIds[0])
}
// Build layers
const layers: string[][] = []
const visited = new Set<string>()
const queue = [...starterBlocks]
// BFS to organize blocks into layers
while (queue.length > 0) {
const currentLayer: string[] = []
const currentLayerSize = queue.length
for (let i = 0; i < currentLayerSize; i++) {
const blockId = queue.shift()!
if (visited.has(blockId)) continue
visited.add(blockId)
currentLayer.push(blockId)
// Add following blocks to queue
const block = yamlWorkflow.blocks[blockId]
if (block.following) {
block.following.forEach(followingId => {
if (!visited.has(followingId)) {
queue.push(followingId)
}
})
}
}
if (currentLayer.length > 0) {
layers.push(currentLayer)
}
}
// Add any remaining blocks as isolated layer
const remainingBlocks = blockIds.filter(id => !visited.has(id))
if (remainingBlocks.length > 0) {
layers.push(remainingBlocks)
}
// Calculate positions
const horizontalSpacing = 600
const verticalSpacing = 200
const startX = 150
const startY = 300
layers.forEach((layer, layerIndex) => {
const layerX = startX + layerIndex * horizontalSpacing
layer.forEach((blockId, blockIndex) => {
const blockY = startY + (blockIndex - layer.length / 2) * verticalSpacing
positions[blockId] = { x: layerX, y: blockY }
})
})
return positions
}
/**
* Convert YAML workflow to importable format
*/
export function convertYamlToWorkflow(yamlWorkflow: YamlWorkflow): ImportResult {
const errors: string[] = []
const warnings: string[] = []
const blocks: ImportedBlock[] = []
const edges: ImportedEdge[] = []
// Validate block references
const referenceErrors = validateBlockReferences(yamlWorkflow)
errors.push(...referenceErrors)
// Validate block types
const { errors: typeErrors, warnings: typeWarnings } = validateBlockTypes(yamlWorkflow)
errors.push(...typeErrors)
warnings.push(...typeWarnings)
if (errors.length > 0) {
return { blocks: [], edges: [], errors, warnings }
}
// Calculate positions
const positions = calculateBlockPositions(yamlWorkflow)
// Convert blocks
Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => {
const position = positions[blockId] || { x: 100, y: 100 }
const importedBlock: ImportedBlock = {
id: blockId,
type: yamlBlock.type,
name: yamlBlock.name,
inputs: yamlBlock.inputs || {},
position,
}
// Add container-specific data
if (yamlBlock.type === 'loop' || yamlBlock.type === 'parallel') {
importedBlock.data = {
width: 500,
height: 300,
type: yamlBlock.type === 'loop' ? 'loopNode' : 'parallelNode',
}
}
blocks.push(importedBlock)
})
// Convert edges from connections
Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => {
if (yamlBlock.following) {
yamlBlock.following.forEach(targetId => {
const edgeId = `${blockId}-${targetId}-${Date.now()}`
const edge: ImportedEdge = {
id: edgeId,
source: blockId,
target: targetId,
sourceHandle: 'source',
targetHandle: 'target',
type: 'workflowEdge',
}
edges.push(edge)
})
}
})
return { blocks, edges, errors, warnings }
}
/**
* Import workflow from YAML and create blocks/edges using workflow functions
*/
export async function importWorkflowFromYaml(
yamlContent: string,
workflowActions: {
addBlock: (
id: string,
type: string,
name: string,
position: { x: number; y: number },
data?: Record<string, any>,
parentId?: string,
extent?: 'parent'
) => void
addEdge: (edge: any) => void
applyAutoLayout: () => void
setSubBlockValue: (blockId: string, subBlockId: string, value: any) => void
getExistingBlocks: () => Record<string, any>
}
): Promise<{ success: boolean; errors: string[]; warnings: string[]; summary?: string }> {
logger.info('Starting YAML workflow import')
try {
// Parse YAML
const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent)
if (!yamlWorkflow || parseErrors.length > 0) {
return { success: false, errors: parseErrors, warnings: [] }
}
// Convert to importable format
const { blocks, edges, errors, warnings } = convertYamlToWorkflow(yamlWorkflow)
if (errors.length > 0) {
return { success: false, errors, warnings }
}
logger.info(`Importing ${blocks.length} blocks and ${edges.length} edges`)
// Check for existing blocks (new workflows already have a starter block)
const existingBlocks = workflowActions.getExistingBlocks()
const existingStarterBlocks = Object.values(existingBlocks).filter((block: any) => block.type === 'starter')
let actualBlocksCreated = 0
let starterBlockId: string | null = null
// Create blocks, but handle starter blocks specially
for (const block of blocks) {
if (block.type === 'starter') {
if (existingStarterBlocks.length > 0) {
// Use existing starter block
const existingStarter = existingStarterBlocks[0] as any
starterBlockId = existingStarter.id
logger.debug(`Using existing starter block: ${starterBlockId}`)
// Update the starter block's inputs if needed
Object.entries(block.inputs).forEach(([inputKey, inputValue]) => {
if (inputValue !== undefined && inputValue !== null && starterBlockId !== null) {
logger.debug(`Setting starter input: ${starterBlockId}.${inputKey} = ${inputValue}`)
workflowActions.setSubBlockValue(starterBlockId, inputKey, inputValue)
}
})
} else {
// Create new starter block with generated ID (let the system generate it)
const generatedId = crypto.randomUUID()
starterBlockId = generatedId
logger.debug(`Creating new starter block: ${generatedId}`)
workflowActions.addBlock(
generatedId,
block.type,
block.name,
block.position,
block.data,
block.parentId,
block.extent
)
actualBlocksCreated++
}
} else {
// Create non-starter blocks with generated IDs to avoid conflicts
const generatedId = crypto.randomUUID()
logger.debug(`Creating block: ${generatedId} (${block.type}) originally ${block.id}`)
workflowActions.addBlock(
generatedId,
block.type,
block.name,
block.position,
block.data,
block.parentId,
block.extent
)
actualBlocksCreated++
// Update edges to use the new generated ID
edges.forEach(edge => {
if (edge.source === block.id) {
edge.source = generatedId
}
if (edge.target === block.id) {
edge.target = generatedId
}
})
// Store mapping for setting inputs later
block.id = generatedId
}
// Update edges to use the starter block ID
if (block.type === 'starter' && starterBlockId !== null) {
const starterId = starterBlockId // TypeScript now knows this is string
edges.forEach(edge => {
if (edge.source === block.id) {
edge.source = starterId
}
if (edge.target === block.id) {
edge.target = starterId
}
})
}
}
// Small delay to ensure blocks are created before adding edges
await new Promise(resolve => setTimeout(resolve, 200))
// Create edges
let edgesCreated = 0
for (const edge of edges) {
try {
logger.debug(`Creating edge: ${edge.source} -> ${edge.target}`)
workflowActions.addEdge({
...edge,
id: crypto.randomUUID() // Generate unique edge ID
})
edgesCreated++
} catch (error) {
logger.warn(`Failed to create edge ${edge.source} -> ${edge.target}:`, error)
warnings.push(`Failed to create connection from ${edge.source} to ${edge.target}`)
}
}
// Small delay before setting input values
await new Promise(resolve => setTimeout(resolve, 200))
// Set input values for non-starter blocks
for (const block of blocks) {
if (block.type !== 'starter') {
Object.entries(block.inputs).forEach(([inputKey, inputValue]) => {
if (inputValue !== undefined && inputValue !== null) {
try {
logger.debug(`Setting input: ${block.id}.${inputKey} = ${inputValue}`)
workflowActions.setSubBlockValue(block.id, inputKey, inputValue)
} catch (error) {
logger.warn(`Failed to set input ${block.id}.${inputKey}:`, error)
warnings.push(`Failed to set input ${inputKey} for block ${block.id}`)
}
}
})
}
}
// Apply auto layout after a delay
setTimeout(() => {
logger.debug('Applying auto layout')
workflowActions.applyAutoLayout()
}, 800)
const summary = `Successfully imported ${actualBlocksCreated} new blocks and ${edgesCreated} connections`
logger.info(summary)
return {
success: true,
errors: [],
warnings,
summary
}
} catch (error) {
const errorMessage = `Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`
logger.error(errorMessage, error)
return {
success: false,
errors: [errorMessage],
warnings: []
}
}
}