fix(yaml): modules that require agent repo (#873)

* fix: import yaml store

* fix: removed local dev modules
This commit is contained in:
Emir Karabeg
2025-08-04 21:07:17 -07:00
committed by GitHub
parent 063734f02c
commit 696ef12c80
6 changed files with 449 additions and 157 deletions

View File

@@ -32,6 +32,7 @@ import {
TooltipTrigger,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -998,10 +999,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
return (
<div className='fixed top-4 right-4 z-20 flex items-center gap-1'>
{renderDisconnectionNotice()}
{renderToggleButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()}
{isExpanded && renderDuplicateButton()}
{!isDev && renderToggleButton()}
{isExpanded && !isDev && <ExportControls />}
{isExpanded && !isDev && renderAutoLayoutButton()}
{!isDev && isExpanded && renderDuplicateButton()}
{isDev && renderDuplicateButton()}
{renderDeleteButton()}
{!isDebugging && renderDebugModeToggle()}
{renderPublishButton()}

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { isDev } from '@/lib/environment'
import { useCopilotStore } from '@/stores/copilot/store'
import { useChatStore } from '@/stores/panel/chat/store'
import { useConsoleStore } from '@/stores/panel/console/store'
@@ -304,14 +305,16 @@ export function Panel() {
>
Console
</button>
<button
onClick={() => handleTabClick('copilot')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Copilot
</button>
{!isDev && (
<button
onClick={() => handleTabClick('copilot')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${
isOpen && activeTab === 'copilot' ? 'panel-tab-active' : 'panel-tab-inactive'
}`}
>
Copilot
</button>
)}
<button
onClick={() => handleTabClick('variables')}
className={`panel-tab-base inline-flex flex-1 cursor-pointer items-center justify-center rounded-[10px] border border-transparent py-1 font-[450] text-sm outline-none transition-colors duration-200 ${

View File

@@ -6,6 +6,7 @@ import { Folder, Plus, Upload } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { isDev } from '@/lib/environment'
import { generateFolderName } from '@/lib/naming'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -347,7 +348,7 @@ export function CreateMenu({ onCreateWorkflow, isCreatingWorkflow = false }: Cre
</button>
{/* Import Workflow */}
{userPermissions.canEdit && (
{userPermissions.canEdit && !isDev && (
<button
className={cn(menuItemClassName, isImporting && 'cursor-not-allowed opacity-50')}
onClick={handleImportWorkflow}

View File

@@ -464,7 +464,7 @@ export function WorkspaceSelector({
{/* Bottom Actions */}
<div className='mt-2 flex items-center gap-2 border-t pt-2'>
{/* Send Invite - Hide in development */}
{isDev && (
{!isDev && (
<Button
variant='secondary'
onClick={userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined}

View File

@@ -1,143 +0,0 @@
# 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,429 @@
import { load as yamlParse } from 'js-yaml'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import {
type ConnectionsFormat,
expandConditionInputs,
type ImportedEdge,
parseBlockConnections,
validateBlockReferences,
validateBlockStructure,
} from '@/stores/workflows/yaml/parsing-utils'
const logger = createLogger('WorkflowYamlImporter')
interface YamlBlock {
type: string
name: string
inputs?: Record<string, any>
connections?: ConnectionsFormat
parentId?: string // Add parentId for nested blocks
}
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 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 unknown
// Validate top-level structure
if (!data || typeof data !== 'object') {
errors.push('Invalid YAML: Root must be an object')
return { data: null, errors }
}
// Type guard to check if data has the expected structure
const parsedData = data as Record<string, unknown>
if (!parsedData.version) {
errors.push('Missing required field: version')
}
if (!parsedData.blocks || typeof parsedData.blocks !== 'object') {
errors.push('Missing or invalid field: blocks')
return { data: null, errors }
}
// Validate blocks structure
const blocks = parsedData.blocks as Record<string, unknown>
Object.entries(blocks).forEach(([blockId, block]: [string, unknown]) => {
if (!block || typeof block !== 'object') {
errors.push(`Invalid block definition for '${blockId}': must be an object`)
return
}
const blockData = block as Record<string, unknown>
if (!blockData.type || typeof blockData.type !== 'string') {
errors.push(`Invalid block '${blockId}': missing or invalid 'type' field`)
}
if (!blockData.name || typeof blockData.name !== 'string') {
errors.push(`Invalid block '${blockId}': missing or invalid 'name' field`)
}
if (blockData.inputs && typeof blockData.inputs !== 'object') {
errors.push(`Invalid block '${blockId}': 'inputs' must be an object`)
}
if (blockData.preceding && !Array.isArray(blockData.preceding)) {
errors.push(`Invalid block '${blockId}': 'preceding' must be an array`)
}
if (blockData.following && !Array.isArray(blockData.following)) {
errors.push(`Invalid block '${blockId}': 'following' must be an array`)
}
})
if (errors.length > 0) {
return { data: null, errors }
}
return { data: parsedData as unknown as YamlWorkflow, errors: [] }
} catch (error) {
errors.push(`YAML parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`)
return { data: null, 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]) => {
// Use shared structure validation
const { errors: structureErrors, warnings: structureWarnings } = validateBlockStructure(
blockId,
block
)
errors.push(...structureErrors)
warnings.push(...structureWarnings)
// 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 incoming connections)
const starterBlocks = blockIds.filter((id) => {
const block = yamlWorkflow.blocks[id]
return !block.connections?.incoming || block.connections.incoming.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.connections?.outgoing) {
block.connections.outgoing.forEach((connection) => {
if (!visited.has(connection.target)) {
queue.push(connection.target)
}
})
}
}
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
}
/**
* Sort blocks to ensure parents are processed before children
* This ensures proper creation order for nested blocks
*/
function sortBlocksByParentChildOrder(blocks: ImportedBlock[]): ImportedBlock[] {
const sorted: ImportedBlock[] = []
const processed = new Set<string>()
const visiting = new Set<string>() // Track blocks currently being processed to detect cycles
// Create a map for quick lookup
const blockMap = new Map<string, ImportedBlock>()
blocks.forEach((block) => blockMap.set(block.id, block))
// Process blocks recursively, ensuring parents are added first
function processBlock(block: ImportedBlock) {
if (processed.has(block.id)) {
return // Already processed
}
if (visiting.has(block.id)) {
// Circular dependency detected - break the cycle by processing this block without its parent
logger.warn(`Circular parent-child dependency detected for block ${block.id}, breaking cycle`)
sorted.push(block)
processed.add(block.id)
return
}
visiting.add(block.id)
// If this block has a parent, ensure the parent is processed first
if (block.parentId) {
const parentBlock = blockMap.get(block.parentId)
if (parentBlock && !processed.has(block.parentId)) {
processBlock(parentBlock)
}
}
// Now process this block
visiting.delete(block.id)
sorted.push(block)
processed.add(block.id)
}
// Process all blocks
blocks.forEach((block) => processBlock(block))
return sorted
}
/**
* 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.blocks)
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 }
// Expand condition inputs from clean format to internal format
const processedInputs =
yamlBlock.type === 'condition'
? expandConditionInputs(blockId, yamlBlock.inputs || {})
: yamlBlock.inputs || {}
const importedBlock: ImportedBlock = {
id: blockId,
type: yamlBlock.type,
name: yamlBlock.name,
inputs: processedInputs,
position,
}
// Add container-specific data
if (yamlBlock.type === 'loop' || yamlBlock.type === 'parallel') {
// For loop/parallel blocks, map the inputs to the data field since they don't use subBlocks
importedBlock.data = {
width: 500,
height: 300,
type: yamlBlock.type === 'loop' ? 'loopNode' : 'parallelNode',
// Map YAML inputs to data properties for loop/parallel blocks
...(yamlBlock.inputs || {}),
}
// Clear inputs since they're now in data
importedBlock.inputs = {}
}
// Handle parent-child relationships for nested blocks
if (yamlBlock.parentId) {
importedBlock.parentId = yamlBlock.parentId
importedBlock.extent = 'parent'
// Also add to data for consistency with how the system works
if (!importedBlock.data) {
importedBlock.data = {}
}
importedBlock.data.parentId = yamlBlock.parentId
importedBlock.data.extent = 'parent'
}
blocks.push(importedBlock)
})
// Convert edges from connections using shared parser
Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => {
const {
edges: blockEdges,
errors: connectionErrors,
warnings: connectionWarnings,
} = parseBlockConnections(blockId, yamlBlock.connections, yamlBlock.type)
edges.push(...blockEdges)
errors.push(...connectionErrors)
warnings.push(...connectionWarnings)
})
// Sort blocks to ensure parents are created before children
const sortedBlocks = sortBlocksByParentChildOrder(blocks)
return { blocks: sortedBlocks, edges, errors, warnings }
}
/**
* Create smart ID mapping that preserves existing block IDs and generates new ones for new blocks
*/
function createSmartIdMapping(
yamlBlocks: ImportedBlock[],
existingBlocks: Record<string, any>,
activeWorkflowId: string,
forceNewIds = false
): Map<string, string> {
const yamlIdToActualId = new Map<string, string>()
const existingBlockIds = new Set(Object.keys(existingBlocks))
logger.info('Creating smart ID mapping', {
activeWorkflowId,
yamlBlockCount: yamlBlocks.length,
existingBlockCount: Object.keys(existingBlocks).length,
existingBlockIds: Array.from(existingBlockIds),
yamlBlockIds: yamlBlocks.map((b) => b.id),
forceNewIds,
})
for (const block of yamlBlocks) {
if (forceNewIds || !existingBlockIds.has(block.id)) {
// Force new ID or block ID doesn't exist in current workflow - generate new UUID
const newId = uuidv4()
yamlIdToActualId.set(block.id, newId)
logger.info(
`🆕 Mapping new block: ${block.id} -> ${newId} (${forceNewIds ? 'forced new ID' : `not found in workflow ${activeWorkflowId}`})`
)
} else {
// Block ID exists in current workflow - preserve it
yamlIdToActualId.set(block.id, block.id)
logger.info(
`✅ Preserving existing block ID: ${block.id} (exists in workflow ${activeWorkflowId})`
)
}
}
logger.info('Smart ID mapping completed', {
mappings: Array.from(yamlIdToActualId.entries()),
preservedCount: Array.from(yamlIdToActualId.entries()).filter(([old, new_]) => old === new_)
.length,
newCount: Array.from(yamlIdToActualId.entries()).filter(([old, new_]) => old !== new_).length,
})
return yamlIdToActualId
}