Yaml language basics

This commit is contained in:
Siddharth Ganesan
2025-07-08 20:42:40 -07:00
parent c0b8e1aca3
commit c7b77bd303
6 changed files with 589 additions and 3 deletions

View File

@@ -0,0 +1,165 @@
'use client'
import { useState } from 'react'
import { Download, FileText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store'
const logger = createLogger('ExportControls')
interface ExportControlsProps {
disabled?: boolean
}
export function ExportControls({ disabled = false }: ExportControlsProps) {
const [isExporting, setIsExporting] = useState(false)
const workflowState = useWorkflowStore()
const { workflows, activeWorkflowId } = useWorkflowRegistry()
const getYaml = useWorkflowYamlStore(state => state.getYaml)
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
const downloadFile = (content: string, filename: string, mimeType: string) => {
try {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
logger.error('Failed to download file:', error)
}
}
const handleExportJson = async () => {
if (!currentWorkflow || !activeWorkflowId) {
logger.warn('No active workflow to export')
return
}
setIsExporting(true)
try {
const exportData = {
workflow: {
id: activeWorkflowId,
name: currentWorkflow.name,
description: currentWorkflow.description,
color: currentWorkflow.color,
},
state: {
blocks: workflowState.blocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
},
exportedAt: new Date().toISOString(),
version: '1.0'
}
const jsonContent = JSON.stringify(exportData, null, 2)
const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '_')}_workflow.json`
downloadFile(jsonContent, filename, 'application/json')
logger.info('Workflow exported as JSON')
} catch (error) {
logger.error('Failed to export workflow as JSON:', error)
} finally {
setIsExporting(false)
}
}
const handleExportYaml = async () => {
if (!currentWorkflow || !activeWorkflowId) {
logger.warn('No active workflow to export')
return
}
setIsExporting(true)
try {
const yamlContent = getYaml()
const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '_')}_workflow.yaml`
downloadFile(yamlContent, filename, 'text/yaml')
logger.info('Workflow exported as YAML')
} catch (error) {
logger.error('Failed to export workflow as YAML:', error)
} finally {
setIsExporting(false)
}
}
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='icon'
disabled={disabled || isExporting || !currentWorkflow}
className='hover:text-foreground'
>
<Download className='h-5 w-5' />
<span className='sr-only'>Export Workflow</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
{disabled
? 'Export not available'
: !currentWorkflow
? 'No workflow to export'
: 'Export Workflow'
}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align='end' className='w-48'>
<DropdownMenuItem
onClick={handleExportJson}
disabled={isExporting || !currentWorkflow}
className='flex items-center gap-2 cursor-pointer'
>
<FileText className='h-4 w-4' />
<div className='flex flex-col'>
<span>Export as JSON</span>
<span className='text-muted-foreground text-xs'>
Full workflow data
</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleExportYaml}
disabled={isExporting || !currentWorkflow}
className='flex items-center gap-2 cursor-pointer'
>
<FileText className='h-4 w-4' />
<div className='flex flex-col'>
<span>Export as YAML</span>
<span className='text-muted-foreground text-xs'>
Condensed workflow language
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -56,6 +56,7 @@ import {
} from '../../../hooks/use-keyboard-shortcuts'
import { useWorkflowExecution } from '../../hooks/use-workflow-execution'
import { DeploymentControls } from './components/deployment-controls/deployment-controls'
import { ExportControls } from './components/export-controls/export-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'
@@ -1287,6 +1288,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{renderDuplicateButton()}
{renderAutoLayoutButton()}
{renderDebugModeToggle()}
<ExportControls disabled={!userPermissions.canRead} />
{/* {renderPublishButton()} */}
{renderDeployButton()}
{renderRunButton()}

View File

@@ -65,6 +65,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@react-email/components": "^0.0.34",
"@sentry/nextjs": "^9.15.0",
"@types/js-yaml": "4.0.9",
"@types/three": "0.177.0",
"@vercel/og": "^0.6.5",
"@vercel/speed-insights": "^1.2.0",
@@ -86,6 +87,7 @@
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",
"jose": "6.0.11",
"js-yaml": "4.1.0",
"jwt-decode": "^4.0.0",
"lenis": "^1.2.3",
"lucide-react": "^0.479.0",

View File

@@ -0,0 +1,143 @@
# Workflow YAML Store
This store dynamically generates a condensed YAML representation of workflows from the JSON workflow state. It extracts input values, connections, and block relationships to create a clean, readable format.
## Features
- **Dynamic Input Extraction**: Automatically reads input values from block configurations and subblock stores
- **Connection Mapping**: Determines preceding and following blocks from workflow edges
- **Type-Aware Processing**: Handles different input types (text, numbers, booleans, objects) appropriately
- **Auto-Refresh**: Automatically updates when workflow state or input values change
- **Clean Format**: Generates well-formatted YAML with proper indentation
## YAML Structure
```yaml
version: "1.0"
blocks:
block-id-1:
type: "starter"
name: "Start"
inputs:
startWorkflow: "manual"
following:
- "block-id-2"
block-id-2:
type: "agent"
name: "AI Agent"
inputs:
systemPrompt: "You are a helpful assistant"
userPrompt: "Process the input data"
model: "gpt-4"
temperature: 0.7
preceding:
- "block-id-1"
following:
- "block-id-3"
```
## Usage
### Basic Usage
```typescript
import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store'
function WorkflowYamlViewer() {
const yaml = useWorkflowYamlStore(state => state.getYaml())
return (
<pre>
<code>{yaml}</code>
</pre>
)
}
```
### Manual Refresh
```typescript
import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store'
function WorkflowControls() {
const refreshYaml = useWorkflowYamlStore(state => state.refreshYaml)
return (
<button onClick={refreshYaml}>
Refresh YAML
</button>
)
}
```
### Advanced Usage
```typescript
import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store'
function WorkflowExporter() {
const { yaml, lastGenerated, generateYaml } = useWorkflowYamlStore()
const exportToFile = () => {
const blob = new Blob([yaml], { type: 'text/yaml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'workflow.yaml'
a.click()
URL.revokeObjectURL(url)
}
return (
<div>
<p>Last generated: {lastGenerated ? new Date(lastGenerated).toLocaleString() : 'Never'}</p>
<button onClick={generateYaml}>Regenerate</button>
<button onClick={exportToFile}>Export YAML</button>
</div>
)
}
```
## Input Types Handled
The store intelligently processes different subblock input types:
- **Text Inputs** (`short-input`, `long-input`): Trimmed strings
- **Dropdowns/Combobox** (`dropdown`, `combobox`): Selected values
- **Tables** (`table`): Arrays of objects (only if non-empty)
- **Code Blocks** (`code`): Preserves formatting for strings and objects
- **Switches** (`switch`): Boolean values
- **Sliders** (`slider`): Numeric values
- **Checkbox Lists** (`checkbox-list`): Arrays of selected items
## Auto-Refresh Behavior
The store automatically refreshes in these scenarios:
1. **Workflow Structure Changes**: When blocks are added, removed, or connections change
2. **Input Value Changes**: When any subblock input values are modified
3. **Debounced Updates**: Changes are debounced to prevent excessive regeneration
## Performance
- **Lazy Generation**: YAML is only generated when requested
- **Caching**: Results are cached and only regenerated when data changes
- **Debouncing**: Rapid changes are debounced to improve performance
- **Selective Updates**: Only regenerates when meaningful changes occur
## Error Handling
If YAML generation fails, the store returns an error message in YAML comment format:
```yaml
# Error generating YAML: [error message]
```
## Dependencies
- `js-yaml`: For YAML serialization
- `zustand`: For state management
- `@/blocks`: For block configuration access
- `@/stores/workflows/workflow/store`: For workflow state
- `@/stores/workflows/subblock/store`: For input values

View File

@@ -0,0 +1,268 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { dump as yamlDump } from 'js-yaml'
import { getBlock } from '@/blocks'
import { createLogger } from '@/lib/logs/console-logger'
import { useWorkflowStore } from '../workflow/store'
import { useSubBlockStore } from '../subblock/store'
import type { BlockState, WorkflowState } from '../workflow/types'
import type { SubBlockConfig } from '@/blocks/types'
const logger = createLogger('WorkflowYamlStore')
interface YamlBlock {
type: string
name: string
inputs?: Record<string, any>
preceding?: string[]
following?: string[]
}
interface YamlWorkflow {
version: string
blocks: Record<string, YamlBlock>
}
interface WorkflowYamlState {
yaml: string
lastGenerated?: number
}
interface WorkflowYamlActions {
generateYaml: () => void
getYaml: () => string
refreshYaml: () => void
}
type WorkflowYamlStore = WorkflowYamlState & WorkflowYamlActions
/**
* Extract input values from a block's subBlocks based on its configuration
*/
function extractBlockInputs(blockState: BlockState, blockId: string): Record<string, any> {
const blockConfig = getBlock(blockState.type)
const subBlockStore = useSubBlockStore.getState()
const inputs: Record<string, any> = {}
if (!blockConfig) {
// For custom blocks like loops/parallels, extract available subBlock values
Object.entries(blockState.subBlocks || {}).forEach(([subBlockId, subBlockState]) => {
const value = subBlockStore.getValue(blockId, subBlockId) ?? subBlockState.value
if (value !== undefined && value !== null && value !== '') {
inputs[subBlockId] = value
}
})
return inputs
}
// Process each subBlock configuration
blockConfig.subBlocks.forEach((subBlockConfig: SubBlockConfig) => {
const subBlockId = subBlockConfig.id
// Skip hidden or conditional fields that aren't active
if (subBlockConfig.hidden) return
// Get value from subblock store or fallback to block state
const value = subBlockStore.getValue(blockId, subBlockId) ??
blockState.subBlocks[subBlockId]?.value
// Include value if it exists and isn't empty
if (value !== undefined && value !== null && value !== '') {
// Handle different input types appropriately
switch (subBlockConfig.type) {
case 'table':
// Tables are arrays of objects
if (Array.isArray(value) && value.length > 0) {
inputs[subBlockId] = value
}
break
case 'checkbox-list':
// Checkbox lists return arrays
if (Array.isArray(value) && value.length > 0) {
inputs[subBlockId] = value
}
break
case 'code':
// Code blocks should preserve formatting
if (typeof value === 'string' && value.trim()) {
inputs[subBlockId] = value
} else if (typeof value === 'object') {
inputs[subBlockId] = value
}
break
case 'switch':
// Boolean values
inputs[subBlockId] = Boolean(value)
break
case 'slider':
// Numeric values
if (typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)))) {
inputs[subBlockId] = Number(value)
}
break
default:
// Text inputs, dropdowns, etc.
if (typeof value === 'string' && value.trim()) {
inputs[subBlockId] = value.trim()
} else if (typeof value === 'object' || typeof value === 'number' || typeof value === 'boolean') {
inputs[subBlockId] = value
}
break
}
}
})
return inputs
}
/**
* Find preceding blocks for a given block ID
*/
function findPrecedingBlocks(blockId: string, edges: any[]): string[] {
return edges
.filter(edge => edge.target === blockId)
.map(edge => edge.source)
.filter((source, index, arr) => arr.indexOf(source) === index) // Remove duplicates
}
/**
* Find following blocks for a given block ID
*/
function findFollowingBlocks(blockId: string, edges: any[]): string[] {
return edges
.filter(edge => edge.source === blockId)
.map(edge => edge.target)
.filter((target, index, arr) => arr.indexOf(target) === index) // Remove duplicates
}
/**
* Generate YAML representation of the workflow
*/
function generateWorkflowYaml(workflowState: WorkflowState): string {
try {
const yamlWorkflow: YamlWorkflow = {
version: '1.0',
blocks: {}
}
// Process each block
Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => {
const inputs = extractBlockInputs(blockState, blockId)
const preceding = findPrecedingBlocks(blockId, workflowState.edges)
const following = findFollowingBlocks(blockId, workflowState.edges)
const yamlBlock: YamlBlock = {
type: blockState.type,
name: blockState.name
}
// Only include inputs if they exist
if (Object.keys(inputs).length > 0) {
yamlBlock.inputs = inputs
}
// Only include connections if they exist
if (preceding.length > 0) {
yamlBlock.preceding = preceding
}
if (following.length > 0) {
yamlBlock.following = following
}
yamlWorkflow.blocks[blockId] = yamlBlock
})
// Convert to YAML with clean formatting
return yamlDump(yamlWorkflow, {
indent: 2,
lineWidth: -1, // Disable line wrapping
noRefs: true,
sortKeys: false
})
} catch (error) {
logger.error('Failed to generate workflow YAML:', error)
return `# Error generating YAML: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
export const useWorkflowYamlStore = create<WorkflowYamlStore>()(
devtools(
(set, get) => ({
yaml: '',
lastGenerated: undefined,
generateYaml: () => {
const workflowState = useWorkflowStore.getState()
const yaml = generateWorkflowYaml(workflowState)
set({
yaml,
lastGenerated: Date.now()
})
},
getYaml: () => {
const currentTime = Date.now()
const { yaml, lastGenerated } = get()
// Auto-refresh if data is stale (older than 1 second) or never generated
if (!lastGenerated || currentTime - lastGenerated > 1000) {
get().generateYaml()
return get().yaml
}
return yaml
},
refreshYaml: () => {
get().generateYaml()
}
}),
{
name: 'workflow-yaml-store'
}
)
)
// Auto-refresh YAML when workflow state changes
let lastWorkflowState: { blockCount: number; edgeCount: number } | null = null
useWorkflowStore.subscribe((state) => {
const currentState = {
blockCount: Object.keys(state.blocks).length,
edgeCount: state.edges.length
}
// Only refresh if the structure has changed
if (!lastWorkflowState ||
lastWorkflowState.blockCount !== currentState.blockCount ||
lastWorkflowState.edgeCount !== currentState.edgeCount) {
lastWorkflowState = currentState
// Debounce the refresh to avoid excessive updates
const refreshYaml = useWorkflowYamlStore.getState().refreshYaml
setTimeout(refreshYaml, 100)
}
})
// Subscribe to subblock store changes
let lastSubBlockChangeTime = 0
useSubBlockStore.subscribe((state) => {
const currentTime = Date.now()
// Debounce rapid changes
if (currentTime - lastSubBlockChangeTime > 100) {
lastSubBlockChangeTime = currentTime
const refreshYaml = useWorkflowYamlStore.getState().refreshYaml
setTimeout(refreshYaml, 100)
}
})

View File

@@ -95,6 +95,7 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@react-email/components": "^0.0.34",
"@sentry/nextjs": "^9.15.0",
"@types/js-yaml": "4.0.9",
"@types/three": "0.177.0",
"@vercel/og": "^0.6.5",
"@vercel/speed-insights": "^1.2.0",
@@ -116,6 +117,7 @@
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",
"jose": "6.0.11",
"js-yaml": "4.1.0",
"jwt-decode": "^4.0.0",
"lenis": "^1.2.3",
"lucide-react": "^0.479.0",
@@ -1324,6 +1326,8 @@
"@types/jest": ["@types/jest@26.0.24", "", { "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" } }, "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -1482,7 +1486,7 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -3472,8 +3476,6 @@
"jest-diff/pretty-format": ["pretty-format@26.6.2", "", { "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^17.0.1" } }, "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg=="],
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"jsondiffpatch/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
@@ -3498,6 +3500,8 @@
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -3844,6 +3848,8 @@
"openai/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"openapi/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"openapi/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],