feat(autolayout): add fitToView on autolayout and reduce horizontal spacing between blocks (#2575)

* feat(autolayout): add fitToView on autolayout and reduce horizontal spacing between blocks

* remove additional yaml code
This commit is contained in:
Waleed
2025-12-24 16:19:29 -08:00
committed by GitHub
parent 77521a3a57
commit 92b2e34d25
20 changed files with 68 additions and 1872 deletions

View File

@@ -1066,7 +1066,6 @@ export async function GET(req: NextRequest) {
model: chat.model,
messages: Array.isArray(chat.messages) ? chat.messages : [],
messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0,
previewYaml: null, // Not needed for chat list
planArtifact: chat.planArtifact || null,
config: chat.config || null,
createdAt: chat.createdAt,

View File

@@ -1,117 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { simAgentClient } from '@/lib/copilot/client'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlAPI')
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
logger.info(`[${requestId}] Converting workflow JSON to YAML`)
const body = await request.json()
const { workflowState, subBlockValues, includeMetadata = false } = body
if (!workflowState) {
return NextResponse.json(
{ success: false, error: 'workflowState is required' },
{ status: 400 }
)
}
// Ensure loop blocks have their data populated with defaults
if (workflowState.blocks) {
Object.entries(workflowState.blocks).forEach(([blockId, block]: [string, any]) => {
if (block.type === 'loop') {
// Ensure data field exists
if (!block.data) {
block.data = {}
}
// Apply defaults if not set
if (!block.data.loopType) {
block.data.loopType = 'for'
}
if (!block.data.count && block.data.count !== 0) {
block.data.count = 5
}
if (!block.data.collection) {
block.data.collection = ''
}
if (!block.data.maxConcurrency) {
block.data.maxConcurrency = 1
}
logger.debug(`[${requestId}] Applied defaults to loop block ${blockId}:`, {
loopType: block.data.loopType,
count: block.data.count,
})
}
})
}
// Gather block registry and utilities for sim-agent
const blocks = getAllBlocks()
const blockRegistry = blocks.reduce(
(acc, block) => {
const blockType = block.type
acc[blockType] = {
...block,
id: blockType,
subBlocks: block.subBlocks || [],
outputs: block.outputs || {},
} as any
return acc
},
{} as Record<string, BlockConfig>
)
// Call sim-agent directly
const result = await simAgentClient.makeRequest('/api/workflow/to-yaml', {
body: {
workflowState,
subBlockValues,
blockRegistry,
utilities: {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
},
},
})
if (!result.success || !result.data?.yaml) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to generate YAML',
},
{ status: result.status || 500 }
)
}
logger.info(`[${requestId}] Successfully generated YAML`, {
yamlLength: result.data.yaml.length,
})
return NextResponse.json({
success: true,
yaml: result.data.yaml,
})
} catch (error) {
logger.error(`[${requestId}] YAML generation failed`, error)
return NextResponse.json(
{
success: false,
error: `Failed to generate YAML: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 500 }
)
}
}

View File

@@ -1,210 +0,0 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { simAgentClient } from '@/lib/copilot/client'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { resolveOutputType } from '@/blocks/utils'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowYamlExportAPI')
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const url = new URL(request.url)
const workflowId = url.searchParams.get('workflowId')
try {
logger.info(`[${requestId}] Exporting workflow YAML from database: ${workflowId}`)
if (!workflowId) {
return NextResponse.json({ success: false, error: 'workflowId is required' }, { status: 400 })
}
// Get the session for authentication
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Fetch the workflow from database
const workflowData = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.then((rows) => rows[0])
if (!workflowData) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Check if user has access to this workflow
let hasAccess = false
// Case 1: User owns the workflow
if (workflowData.userId === userId) {
hasAccess = true
}
// Case 2: Workflow belongs to a workspace the user has permissions for
if (!hasAccess && workflowData.workspaceId) {
const userPermission = await getUserEntityPermissions(
userId,
'workspace',
workflowData.workspaceId
)
if (userPermission !== null) {
hasAccess = true
}
}
if (!hasAccess) {
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Try to load from normalized tables first
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
let workflowState: any
const subBlockValues: Record<string, Record<string, any>> = {}
if (normalizedData) {
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
blocksCount: Object.keys(normalizedData.blocks).length,
edgesCount: normalizedData.edges.length,
})
// Use normalized table data - construct state from normalized tables
workflowState = {
deploymentStatuses: {},
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
}
// Extract subblock values from the normalized blocks
Object.entries(normalizedData.blocks).forEach(([blockId, block]: [string, any]) => {
subBlockValues[blockId] = {}
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => {
if (subBlock && typeof subBlock === 'object' && 'value' in subBlock) {
subBlockValues[blockId][subBlockId] = subBlock.value
}
})
}
})
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
} else {
return NextResponse.json(
{ success: false, error: 'Workflow has no normalized data' },
{ status: 400 }
)
}
// Ensure loop blocks have their data populated with defaults
if (workflowState.blocks) {
Object.entries(workflowState.blocks).forEach(([blockId, block]: [string, any]) => {
if (block.type === 'loop') {
// Ensure data field exists
if (!block.data) {
block.data = {}
}
// Apply defaults if not set
if (!block.data.loopType) {
block.data.loopType = 'for'
}
if (!block.data.count && block.data.count !== 0) {
block.data.count = 5
}
if (!block.data.collection) {
block.data.collection = ''
}
if (!block.data.maxConcurrency) {
block.data.maxConcurrency = 1
}
logger.debug(`[${requestId}] Applied defaults to loop block ${blockId}:`, {
loopType: block.data.loopType,
count: block.data.count,
})
}
})
}
// Gather block registry and utilities for sim-agent
const blocks = getAllBlocks()
const blockRegistry = blocks.reduce(
(acc, block) => {
const blockType = block.type
acc[blockType] = {
...block,
id: blockType,
subBlocks: block.subBlocks || [],
outputs: block.outputs || {},
} as any
return acc
},
{} as Record<string, BlockConfig>
)
// Call sim-agent directly
const result = await simAgentClient.makeRequest('/api/workflow/to-yaml', {
body: {
workflowState,
subBlockValues,
blockRegistry,
utilities: {
generateLoopBlocks: generateLoopBlocks.toString(),
generateParallelBlocks: generateParallelBlocks.toString(),
resolveOutputType: resolveOutputType.toString(),
},
},
})
if (!result.success || !result.data?.yaml) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to generate YAML',
},
{ status: result.status || 500 }
)
}
logger.info(`[${requestId}] Successfully generated YAML from database`, {
yamlLength: result.data.yaml.length,
})
return NextResponse.json({
success: true,
yaml: result.data.yaml,
})
} catch (error) {
logger.error(`[${requestId}] YAML export failed`, error)
return NextResponse.json(
{
success: false,
error: `Failed to export YAML: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 500 }
)
}
}

View File

@@ -14,7 +14,6 @@ const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
// Optimized: Single diff store subscription
const {
isShowingDiff,
isDiffReady,
@@ -38,12 +37,10 @@ export const DiffControls = memo(function DiffControls() {
)
)
// Optimized: Single copilot store subscription for needed values
const { updatePreviewToolCallState, clearPreviewYaml, currentChat, messages } = useCopilotStore(
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
clearPreviewYaml: state.clearPreviewYaml,
currentChat: state.currentChat,
messages: state.messages,
}),
@@ -222,11 +219,6 @@ export const DiffControls = memo(function DiffControls() {
logger.warn('Failed to create checkpoint before accept:', error)
})
// Clear preview YAML immediately
await clearPreviewYaml().catch((error) => {
logger.warn('Failed to clear preview YAML:', error)
})
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
@@ -266,16 +258,11 @@ export const DiffControls = memo(function DiffControls() {
logger.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
}, [createCheckpoint, clearPreviewYaml, updatePreviewToolCallState, acceptChanges])
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')
// Clear preview YAML immediately
clearPreviewYaml().catch((error) => {
logger.warn('Failed to clear preview YAML:', error)
})
// Resolve target toolCallId for build/edit and update to terminal rejected state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
@@ -306,7 +293,7 @@ export const DiffControls = memo(function DiffControls() {
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
})
}, [clearPreviewYaml, updatePreviewToolCallState, rejectChanges])
}, [updatePreviewToolCallState, rejectChanges])
// Don't show anything if no diff is available or diff is not ready
if (!hasActiveDiff || !isDiffReady) {

View File

@@ -65,56 +65,6 @@ export function useMessageFeedback(
return null
}, [messages, message.id])
/**
* Extracts workflow YAML from workflow tool calls
*/
const getWorkflowYaml = useCallback(() => {
const allToolCalls = [
...(message.toolCalls || []),
...(message.contentBlocks || [])
.filter((block) => block.type === 'tool_call')
.map((block) => (block as any).toolCall),
]
const workflowTools = allToolCalls.filter((toolCall) =>
WORKFLOW_TOOL_NAMES.includes(toolCall?.name)
)
for (const toolCall of workflowTools) {
const yamlContent =
toolCall.result?.yamlContent ||
toolCall.result?.data?.yamlContent ||
toolCall.input?.yamlContent ||
toolCall.input?.data?.yamlContent
if (yamlContent && typeof yamlContent === 'string' && yamlContent.trim()) {
return yamlContent
}
}
if (currentChat?.previewYaml?.trim()) {
return currentChat.previewYaml
}
for (const toolCall of workflowTools) {
if (toolCall.id) {
const preview = getPreviewByToolCall(toolCall.id)
if (preview?.yamlContent?.trim()) {
return preview.yamlContent
}
}
}
if (workflowTools.length > 0 && workflowId) {
const latestPreview = getLatestPendingPreview(workflowId, currentChat?.id)
if (latestPreview?.yamlContent?.trim()) {
return latestPreview.yamlContent
}
}
return null
}, [message, currentChat, workflowId, getPreviewByToolCall, getLatestPendingPreview])
/**
* Submits feedback to the API
*/
@@ -137,20 +87,14 @@ export function useMessageFeedback(
return
}
const workflowYaml = getWorkflowYaml()
try {
const requestBody: any = {
const requestBody = {
chatId: currentChat.id,
userQuery,
agentResponse,
isPositiveFeedback: isPositive,
}
if (workflowYaml) {
requestBody.workflowYaml = workflowYaml
}
const response = await fetch('/api/copilot/feedback', {
method: 'POST',
headers: {
@@ -168,7 +112,7 @@ export function useMessageFeedback(
logger.error('Error submitting feedback:', error)
}
},
[currentChat, getLastUserQuery, getFullAssistantContent, message, getWorkflowYaml]
[currentChat, getLastUserQuery, getFullAssistantContent, message]
)
/**

View File

@@ -37,6 +37,7 @@ import {
useUsageLimits,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
import { Variables } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/variables/variables'
import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { useChatStore } from '@/stores/chat/store'
@@ -99,6 +100,7 @@ export function Panel() {
hydration.phase === 'state-loading'
const { getJson } = useWorkflowJsonStore()
const { blocks } = useWorkflowStore()
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
// Delete workflow hook
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
@@ -201,22 +203,11 @@ export function Panel() {
setIsAutoLayouting(true)
try {
// Use the standalone auto layout utility for immediate frontend updates
const { applyAutoLayoutAndUpdateStore } = await import('../../utils')
const result = await applyAutoLayoutAndUpdateStore(activeWorkflowId!)
if (result.success) {
logger.info('Auto layout completed successfully')
} else {
logger.error('Auto layout failed:', result.error)
}
} catch (error) {
logger.error('Auto layout error:', error)
await autoLayoutWithFitView()
} finally {
setIsAutoLayouting(false)
}
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, activeWorkflowId])
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
/**
* Handles exporting workflow as JSON

View File

@@ -1,14 +1,21 @@
import { useCallback } from 'react'
import type { AutoLayoutOptions } from '../utils/auto-layout-utils'
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '../utils/auto-layout-utils'
import { useReactFlow } from 'reactflow'
import { createLogger } from '@/lib/logs/console/logger'
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
export type { AutoLayoutOptions }
const logger = createLogger('useAutoLayout')
/**
* Hook providing auto-layout functionality for workflows
* Binds workflowId context and provides memoized callback for React components
* Includes automatic fitView animation after successful layout
*/
export function useAutoLayout(workflowId: string | null) {
const { fitView } = useReactFlow()
const applyAutoLayoutAndUpdateStore = useCallback(
async (options: AutoLayoutOptions = {}) => {
if (!workflowId) {
@@ -19,7 +26,34 @@ export function useAutoLayout(workflowId: string | null) {
[workflowId]
)
/**
* Applies auto-layout and animates to fit all blocks in view
*/
const handleAutoLayout = useCallback(async () => {
try {
const result = await applyAutoLayoutAndUpdateStore()
if (result.success) {
logger.info('Auto layout completed successfully')
requestAnimationFrame(() => {
fitView({ padding: 0.8, duration: 600 })
})
} else {
logger.error('Auto layout failed:', result.error)
}
return result
} catch (error) {
logger.error('Auto layout error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}, [applyAutoLayoutAndUpdateStore, fitView])
return {
applyAutoLayoutAndUpdateStore,
handleAutoLayout,
}
}

View File

@@ -198,7 +198,7 @@ const WorkflowContent = React.memo(() => {
return resizeLoopNodes(updateNodeDimensions)
}, [resizeLoopNodes, updateNodeDimensions])
const { applyAutoLayoutAndUpdateStore } = useAutoLayout(activeWorkflowId || null)
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks])
@@ -441,19 +441,8 @@ const WorkflowContent = React.memo(() => {
/** Applies auto-layout to the workflow canvas. */
const handleAutoLayout = useCallback(async () => {
if (Object.keys(blocks).length === 0) return
try {
const result = await applyAutoLayoutAndUpdateStore()
if (result.success) {
logger.info('Auto layout completed successfully')
} else {
logger.error('Auto layout failed:', result.error)
}
} catch (error) {
logger.error('Auto layout error:', error)
}
}, [blocks, applyAutoLayoutAndUpdateStore])
await autoLayoutWithFitView()
}, [blocks, autoLayoutWithFitView])
const debouncedAutoLayout = useCallback(() => {
const debounceTimer = setTimeout(() => {

View File

@@ -19,6 +19,7 @@ const sleep = async (ms: number, options: SleepOptions = {}): Promise<boolean> =
}
return new Promise((resolve) => {
// biome-ignore lint/style/useConst: Variable is assigned after closure definitions that reference it
let mainTimeoutId: NodeJS.Timeout | undefined
let checkIntervalId: NodeJS.Timeout | undefined
let resolved = false

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'
import type { Edge } from 'reactflow'
import { useSession } from '@/lib/auth/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useSocket } from '@/app/workspace/providers/socket-provider'
@@ -1326,8 +1327,8 @@ export function useCollaborativeWorkflow() {
// Generate new ID and calculate position
const newId = crypto.randomUUID()
const offsetPosition = {
x: sourceBlock.position.x + 250,
y: sourceBlock.position.y + 20,
x: sourceBlock.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: sourceBlock.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks)

View File

@@ -40,7 +40,6 @@ export interface CopilotChat {
model: string
messages: CopilotMessage[]
messageCount: number
previewYaml: string | null
planArtifact: string | null
config: CopilotChatConfig | null
createdAt: Date

View File

@@ -41,7 +41,6 @@ export const ToolIds = z.enum([
])
export type ToolId = z.infer<typeof ToolIds>
// Base SSE wrapper for tool_call events emitted by the LLM
const ToolCallSSEBase = z.object({
type: z.literal('tool_call'),
data: z.object({
@@ -53,18 +52,14 @@ const ToolCallSSEBase = z.object({
})
export type ToolCallSSE = z.infer<typeof ToolCallSSEBase>
// Reusable small schemas
const StringArray = z.array(z.string())
const BooleanOptional = z.boolean().optional()
const NumberOptional = z.number().optional()
// Tool argument schemas (per SSE examples provided)
export const ToolArgSchemas = {
get_user_workflow: z.object({}),
// New tools
list_user_workflows: z.object({}),
get_workflow_from_name: z.object({ workflow_name: z.string() }),
// Workflow data tool (variables, custom tools, MCP tools, files)
get_workflow_data: z.object({
data_type: z.enum(['global_variables', 'custom_tools', 'mcp_tools', 'files']),
}),
@@ -377,7 +372,6 @@ export type ToolSSESchemaMap = typeof ToolSSESchemas
// Known result schemas per tool (what tool_result.result should conform to)
// Note: Where legacy variability exists, schema captures the common/expected shape for new runtime.
const BuildOrEditWorkflowResult = z.object({
yamlContent: z.string(),
description: z.string().optional(),
workflowState: z.unknown().optional(),
data: z
@@ -411,14 +405,9 @@ const ExecutionEntry = z.object({
})
export const ToolResultSchemas = {
get_user_workflow: z.object({ yamlContent: z.string() }).or(z.string()),
// New tools
get_user_workflow: z.string(),
list_user_workflows: z.object({ workflow_names: z.array(z.string()) }),
get_workflow_from_name: z
.object({ yamlContent: z.string() })
.or(z.object({ userWorkflow: z.string() }))
.or(z.string()),
// Workflow data tool results (variables, custom tools, MCP tools, files)
get_workflow_from_name: z.object({ userWorkflow: z.string() }).or(z.string()),
get_workflow_data: z.union([
z.object({
variables: z.array(z.object({ id: z.string(), name: z.string(), value: z.any() })),
@@ -462,7 +451,6 @@ export const ToolResultSchemas = {
set_global_workflow_variables: z
.object({ variables: z.record(z.any()) })
.or(z.object({ message: z.any().optional(), data: z.any().optional() })),
// New
oauth_request_access: z.object({
granted: z.boolean().optional(),
message: z.string().optional(),
@@ -685,7 +673,6 @@ export const ToolResultSchemas = {
} as const
export type ToolResultSchemaMap = typeof ToolResultSchemas
// Consolidated registry entry per tool
export const ToolRegistry = Object.freeze(
(Object.keys(ToolArgSchemas) as ToolId[]).reduce(
(acc, toolId) => {
@@ -703,7 +690,6 @@ export const ToolRegistry = Object.freeze(
)
export type ToolRegistryMap = typeof ToolRegistry
// Convenience helper types inferred from schemas
export type InferArgs<T extends ToolId> = z.infer<(typeof ToolArgSchemas)[T]>
export type InferResult<T extends ToolId> = z.infer<(typeof ToolResultSchemas)[T]>
export type InferToolCallSSE<T extends ToolId> = z.infer<(typeof ToolSSESchemas)[T]>

View File

@@ -11,13 +11,21 @@ export { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
/**
* Horizontal spacing between layers (columns)
*/
export const DEFAULT_HORIZONTAL_SPACING = 250
export const DEFAULT_HORIZONTAL_SPACING = 180
/**
* Vertical spacing between blocks in the same layer
*/
export const DEFAULT_VERTICAL_SPACING = 200
/**
* Default offset when duplicating blocks
*/
export const DEFAULT_DUPLICATE_OFFSET = {
x: 180,
y: 20,
} as const
/**
* General container padding for layout calculations
*/
@@ -78,15 +86,10 @@ export const DEFAULT_LAYOUT_OPTIONS = {
}
/**
* Default horizontal spacing for containers (tighter than root level)
*/
export const DEFAULT_CONTAINER_HORIZONTAL_SPACING = 250
/**
* Container-specific layout options (tighter spacing for nested layouts)
* Container-specific layout options (same spacing as root level for consistency)
*/
export const CONTAINER_LAYOUT_OPTIONS = {
horizontalSpacing: DEFAULT_CONTAINER_HORIZONTAL_SPACING,
horizontalSpacing: DEFAULT_HORIZONTAL_SPACING,
verticalSpacing: DEFAULT_VERTICAL_SPACING,
padding: { x: CONTAINER_PADDING_X, y: CONTAINER_PADDING_Y },
}

View File

@@ -8,7 +8,6 @@ import type { CopilotMessage, CopilotToolCall } from '@/stores/panel/copilot/typ
export interface PreviewData {
id: string
workflowState: any
yamlContent: string
description?: string
timestamp: number
status: 'pending' | 'accepted' | 'rejected'

View File

@@ -2520,14 +2520,6 @@ export const useCopilotStore = create<CopilotStore>()(
return messageCheckpoints[messageId] || []
},
// Preview YAML (stubbed/no-op)
setPreviewYaml: async (_yamlContent: string) => {},
clearPreviewYaml: async () => {
set((state) => ({
currentChat: state.currentChat ? { ...state.currentChat, previewYaml: null } : null,
}))
},
// Handle streaming response
handleStreamingResponse: async (
stream: ReadableStream,
@@ -2685,7 +2677,6 @@ export const useCopilotStore = create<CopilotStore>()(
model: selectedModel,
messages: get().messages,
messageCount: get().messages.length,
previewYaml: null,
planArtifact: streamingPlanContent || null,
config: {
mode,
@@ -2843,10 +2834,6 @@ export const useCopilotStore = create<CopilotStore>()(
}
},
// Diff updates are out of scope for minimal store
updateDiffStore: async (_yamlContent: string) => {},
updateDiffStoreWithWorkflowState: async (_workflowState: any) => {},
setSelectedModel: async (model) => {
logger.info('[Context Usage] Model changed', { from: get().selectedModel, to: model })
set({ selectedModel: model })

View File

@@ -188,9 +188,6 @@ export interface CopilotActions {
revertToCheckpoint: (checkpointId: string) => Promise<void>
getCheckpointsForMessage: (messageId: string) => any[]
setPreviewYaml: (yamlContent: string) => Promise<void>
clearPreviewYaml: () => Promise<void>
clearMessages: () => void
clearError: () => void
clearSaveError: () => void
@@ -217,8 +214,6 @@ export interface CopilotActions {
triggerUserMessageId?: string
) => Promise<void>
handleNewChatCreation: (newChatId: string) => Promise<void>
updateDiffStore: (yamlContent: string, toolName?: string) => Promise<void>
updateDiffStoreWithWorkflowState: (workflowState: any, toolName?: string) => Promise<void>
executeIntegrationTool: (toolCallId: string) => Promise<void>
skipIntegrationTool: (toolCallId: string) => void
loadAutoAllowedTools: () => Promise<void>

View File

@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks'
@@ -591,8 +592,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + 250,
y: block.position.y + 20,
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(block.name, get().blocks)

View File

@@ -1,504 +0,0 @@
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 { normalizeName } from '@/executor/constants'
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[] = []
// Precompute counts that are used in validations to avoid O(n^2) checks
const apiTriggerCount = Object.values(yamlWorkflow.blocks).filter(
(b) => b.type === 'api_trigger'
).length
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}'`
)
}
})
}
})
// Enforce only one API trigger in YAML (single check outside the loop)
if (apiTriggerCount > 1) {
errors.push('Only one API trigger is allowed per workflow (YAML contains multiple).')
}
return { errors, warnings }
}
/**
* Validates block names are non-empty and unique (by normalized name).
*/
function validateBlockNames(blocks: Record<string, YamlBlock>): string[] {
const errors: string[] = []
const seen = new Map<string, string>()
for (const [blockId, block] of Object.entries(blocks)) {
const normalized = normalizeName(block.name)
if (!normalized) {
errors.push(`Block "${blockId}" has empty name`)
continue
}
const existingBlockId = seen.get(normalized)
if (existingBlockId) {
errors.push(
`Block "${blockId}" has same name as "${existingBlockId}" (normalized: "${normalized}")`
)
} else {
seen.set(normalized, blockId)
}
}
return errors
}
/**
* 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
// First pass: position all blocks as if they're root level
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 }
})
})
// Second pass: adjust positions for child blocks to be relative to their parent
Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => {
if (block.parentId && positions[blockId] && positions[block.parentId]) {
// Convert absolute position to relative position within parent
const parentPos = positions[block.parentId]
const childPos = positions[blockId]
// Calculate relative position inside the parent container
// Start child blocks at a reasonable offset inside the parent
positions[blockId] = {
x: 50 + (childPos.x - parentPos.x) * 0.3, // Scale down and offset
y: 100 + (childPos.y - parentPos.y) * 0.3, // Scale down and offset
}
}
})
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)
// Validate block names (non-empty and unique)
const nameErrors = validateBlockNames(yamlWorkflow.blocks)
errors.push(...nameErrors)
if (errors.length > 0) {
return { blocks: [], edges: [], errors, warnings }
}
const positions = calculateBlockPositions(yamlWorkflow)
Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => {
const position = positions[blockId] || { x: 100, y: 100 }
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
const inputs = yamlBlock.inputs || {}
// Apply defaults for loop blocks
if (yamlBlock.type === 'loop') {
importedBlock.data = {
width: 500,
height: 300,
type: 'subflowNode',
loopType: inputs.loopType || 'for',
count: inputs.iterations || inputs.count || 5,
collection: inputs.collection || '',
maxConcurrency: inputs.maxConcurrency || 1,
// Include any other inputs provided
...inputs,
}
} else {
// Parallel blocks
importedBlock.data = {
width: 500,
height: 300,
type: 'subflowNode',
...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' // Always 'parent' when parentId exists
// 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' // Always 'parent' when parentId exists
}
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
}

View File

@@ -1,714 +0,0 @@
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { EDGE } from '@/executor/constants'
const logger = createLogger('YamlParsingUtils')
export interface ImportedEdge {
id: string
source: string
target: string
sourceHandle: string
targetHandle: string
type: string
}
export interface ParsedConnections {
edges: ImportedEdge[]
errors: string[]
warnings: string[]
}
export interface ConnectionsFormat {
// New format - grouped by handle type
success?: string | string[]
error?: string | string[]
conditions?: Record<string, string | string[]>
loop?: {
start?: string | string[]
end?: string | string[]
}
parallel?: {
start?: string | string[]
end?: string | string[]
}
// Direct handle format (alternative to nested format above)
'loop-start-source'?: string | string[]
'loop-end-source'?: string | string[]
'parallel-start-source'?: string | string[]
'parallel-end-source'?: string | string[]
// Legacy format support
incoming?: Array<{
source: string
sourceHandle?: string
targetHandle?: string
}>
outgoing?: Array<{
target: string
sourceHandle?: string
targetHandle?: string
}>
}
/**
* Parse block connections from both new grouped format and legacy format
*/
export function parseBlockConnections(
blockId: string,
connections: ConnectionsFormat | undefined,
blockType?: string
): ParsedConnections {
const edges: ImportedEdge[] = []
const errors: string[] = []
const warnings: string[] = []
if (!connections) {
return { edges, errors, warnings }
}
// Handle new grouped format
if (hasNewFormat(connections)) {
parseNewFormatConnections(blockId, connections, edges, errors, warnings, blockType)
}
// Handle legacy format (for backwards compatibility)
if (connections.outgoing) {
parseLegacyOutgoingConnections(blockId, connections.outgoing, edges, errors, warnings)
}
return { edges, errors, warnings }
}
/**
* Generate connections in the new grouped format from edges
*/
export function generateBlockConnections(
blockId: string,
edges: ImportedEdge[] | any[]
): ConnectionsFormat {
const connections: ConnectionsFormat = {}
const outgoingEdges = edges.filter((edge) => edge.source === blockId)
if (outgoingEdges.length === 0) {
return connections
}
// Group edges by source handle type
const successTargets: string[] = []
const errorTargets: string[] = []
const conditionTargets: Record<string, string[]> = {}
const loopTargets: { start: string[]; end: string[] } = { start: [], end: [] }
const parallelTargets: { start: string[]; end: string[] } = { start: [], end: [] }
// Track condition ordering for clean sequential else-if naming
const rawConditionIds: string[] = []
for (const edge of outgoingEdges) {
const handle = edge.sourceHandle ?? 'source'
if (handle === 'source') {
successTargets.push(edge.target)
} else if (handle === 'error') {
errorTargets.push(edge.target)
} else if (handle.startsWith(EDGE.CONDITION_PREFIX)) {
const rawConditionId = extractConditionId(handle)
rawConditionIds.push(rawConditionId)
if (!conditionTargets[rawConditionId]) {
conditionTargets[rawConditionId] = []
}
conditionTargets[rawConditionId].push(edge.target)
} else if (handle === 'loop-start-source') {
loopTargets.start.push(edge.target)
} else if (handle === 'loop-end-source') {
loopTargets.end.push(edge.target)
} else if (handle === 'parallel-start-source') {
parallelTargets.start.push(edge.target)
} else if (handle === 'parallel-end-source') {
parallelTargets.end.push(edge.target)
}
}
// Create clean condition mapping for timestamp-based else-if IDs
const cleanConditionTargets: Record<string, string[]> = {}
let elseIfCount = 0
Object.entries(conditionTargets).forEach(([rawId, targets]) => {
let cleanId = rawId
// Simple check: if this is exactly 'else', keep it as 'else'
if (rawId === 'else') {
cleanId = 'else'
}
// Convert timestamp-based else-if IDs to clean sequential format
else if (rawId.startsWith('else-if-') && /else-if-\d+$/.test(rawId)) {
elseIfCount++
if (elseIfCount === 1) {
cleanId = 'else-if'
} else {
cleanId = `else-if-${elseIfCount}`
}
}
cleanConditionTargets[cleanId] = targets
})
// After processing all conditions, check if we need to convert the last else-if to else
// If we have more than the expected number of else-if conditions, the last one should be else
const conditionKeys = Object.keys(cleanConditionTargets)
const hasElse = conditionKeys.includes('else')
const elseIfKeys = conditionKeys.filter((key) => key.startsWith('else-if'))
if (!hasElse && elseIfKeys.length > 0) {
// Find the highest numbered else-if and convert it to else
const highestElseIf = elseIfKeys.sort((a, b) => {
const aNum = a === 'else-if' ? 1 : Number.parseInt(a.replace('else-if-', ''))
const bNum = b === 'else-if' ? 1 : Number.parseInt(b.replace('else-if-', ''))
return bNum - aNum
})[0]
// Move the targets from the highest else-if to else
cleanConditionTargets.else = cleanConditionTargets[highestElseIf]
delete cleanConditionTargets[highestElseIf]
}
// Add to connections object (use single values for single targets, arrays for multiple)
if (successTargets.length > 0) {
connections.success = successTargets.length === 1 ? successTargets[0] : successTargets
}
if (errorTargets.length > 0) {
connections.error = errorTargets.length === 1 ? errorTargets[0] : errorTargets
}
if (Object.keys(cleanConditionTargets).length > 0) {
connections.conditions = {}
// Sort condition keys to maintain consistent order: if, else-if, else-if-2, ..., else
const sortedConditionKeys = Object.keys(cleanConditionTargets).sort((a, b) => {
// Define the order priority
const getOrder = (key: string): number => {
if (key === 'if') return 0
if (key === 'else-if') return 1
if (key.startsWith('else-if-')) {
const num = Number.parseInt(key.replace('else-if-', ''), 10)
return 1 + num // else-if-2 = 3, else-if-3 = 4, etc.
}
if (key === 'else') return 1000 // Always last
return 500 // Other conditions in the middle
}
return getOrder(a) - getOrder(b)
})
// Build the connections object in the correct order
for (const conditionId of sortedConditionKeys) {
const targets = cleanConditionTargets[conditionId]
connections.conditions[conditionId] = targets.length === 1 ? targets[0] : targets
}
}
if (loopTargets.start.length > 0 || loopTargets.end.length > 0) {
connections.loop = {}
if (loopTargets.start.length > 0) {
connections.loop.start =
loopTargets.start.length === 1 ? loopTargets.start[0] : loopTargets.start
}
if (loopTargets.end.length > 0) {
connections.loop.end = loopTargets.end.length === 1 ? loopTargets.end[0] : loopTargets.end
}
}
if (parallelTargets.start.length > 0 || parallelTargets.end.length > 0) {
connections.parallel = {}
if (parallelTargets.start.length > 0) {
connections.parallel.start =
parallelTargets.start.length === 1 ? parallelTargets.start[0] : parallelTargets.start
}
if (parallelTargets.end.length > 0) {
connections.parallel.end =
parallelTargets.end.length === 1 ? parallelTargets.end[0] : parallelTargets.end
}
}
return connections
}
/**
* Validate block structure (type, name, inputs)
*/
export function validateBlockStructure(
blockId: string,
block: any
): { errors: string[]; warnings: string[] } {
const errors: string[] = []
const warnings: string[] = []
if (!block || typeof block !== 'object') {
errors.push(`Invalid block definition for '${blockId}': must be an object`)
return { errors, warnings }
}
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`)
}
return { errors, warnings }
}
/**
* Clean up condition inputs to remove UI state and use semantic format
* Preserves actual condition IDs that match connections
*/
export function cleanConditionInputs(
blockId: string,
inputs: Record<string, any>
): Record<string, any> {
const cleanInputs = { ...inputs }
// Handle condition blocks specially
if (cleanInputs.conditions) {
try {
// Parse the JSON string conditions
const conditions =
typeof cleanInputs.conditions === 'string'
? JSON.parse(cleanInputs.conditions)
: cleanInputs.conditions
if (Array.isArray(conditions)) {
// Convert to clean format, preserving actual IDs for connection mapping
const tempConditions: Array<{ key: string; value: string }> = []
// Track else-if count for clean numbering
let elseIfCount = 0
conditions.forEach((condition: any) => {
if (condition.title && condition.value !== undefined) {
// Create clean semantic keys instead of preserving timestamps
let key = condition.title
if (condition.title === 'else if') {
elseIfCount++
if (elseIfCount === 1) {
key = 'else-if'
} else {
key = `else-if-${elseIfCount}`
}
}
const stringValue = String(condition.value || '')
if (stringValue.trim()) {
tempConditions.push({ key, value: stringValue.trim() })
}
}
})
// Sort conditions to maintain consistent order: if, else-if, else-if-2, ..., else
tempConditions.sort((a, b) => {
const getOrder = (key: string): number => {
if (key === 'if') return 0
if (key === 'else-if') return 1
if (key.startsWith('else-if-')) {
const num = Number.parseInt(key.replace('else-if-', ''), 10)
return 1 + num // else-if-2 = 3, else-if-3 = 4, etc.
}
if (key === 'else') return 1000 // Always last
return 500 // Other conditions in the middle
}
return getOrder(a.key) - getOrder(b.key)
})
// Build the final ordered object
const cleanConditions: Record<string, string> = {}
tempConditions.forEach(({ key, value }) => {
cleanConditions[key] = value
})
// Replace the verbose format with clean format
if (Object.keys(cleanConditions).length > 0) {
cleanInputs.conditions = cleanConditions
} else {
cleanInputs.conditions = undefined
}
}
} catch (error) {
// If parsing fails, leave as-is with a warning
logger.warn(`Failed to clean condition inputs for block ${blockId}:`, error)
}
}
return cleanInputs
}
/**
* Convert clean condition inputs back to internal format for import
*/
export function expandConditionInputs(
blockId: string,
inputs: Record<string, any>
): Record<string, any> {
const expandedInputs = { ...inputs }
// Handle clean condition format
if (
expandedInputs.conditions &&
typeof expandedInputs.conditions === 'object' &&
!Array.isArray(expandedInputs.conditions)
) {
const conditionsObj = expandedInputs.conditions as Record<string, string>
const conditionsArray: any[] = []
Object.entries(conditionsObj).forEach(([key, value]) => {
const conditionId = `${blockId}-${key}`
// Determine display title from key
let title = key
if (key.startsWith('else-if')) {
title = 'else if'
}
conditionsArray.push({
id: conditionId,
title: title,
value: String(value || ''),
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
})
})
// Add default else if not present and no existing else key
const hasElse = Object.keys(conditionsObj).some((key) => key === 'else')
if (!hasElse) {
conditionsArray.push({
id: `${blockId}-else`,
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
})
}
expandedInputs.conditions = JSON.stringify(conditionsArray)
}
return expandedInputs
}
/**
* Validate that block references in connections exist
*/
export function validateBlockReferences(blocks: Record<string, any>): string[] {
const errors: string[] = []
const blockIds = new Set(Object.keys(blocks))
Object.entries(blocks).forEach(([blockId, block]) => {
if (!block.connections) return
const { edges } = parseBlockConnections(blockId, block.connections, block.type)
edges.forEach((edge) => {
if (!blockIds.has(edge.target)) {
errors.push(`Block '${blockId}' references non-existent target block '${edge.target}'`)
}
})
// Check parent references
if (block.parentId && !blockIds.has(block.parentId)) {
errors.push(`Block '${blockId}' references non-existent parent block '${block.parentId}'`)
}
})
return errors
}
// Helper functions
function hasNewFormat(connections: ConnectionsFormat): boolean {
return !!(
connections.success ||
connections.error ||
connections.conditions ||
connections.loop ||
connections.parallel
)
}
function parseNewFormatConnections(
blockId: string,
connections: ConnectionsFormat,
edges: ImportedEdge[],
errors: string[],
warnings: string[],
blockType?: string
) {
// Parse success connections
if (connections.success) {
const targets = Array.isArray(connections.success) ? connections.success : [connections.success]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'source', 'target'))
} else {
errors.push(`Invalid success target in block '${blockId}': must be a string`)
}
})
}
// Parse error connections
if (connections.error) {
const targets = Array.isArray(connections.error) ? connections.error : [connections.error]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'error', 'target'))
} else {
errors.push(`Invalid error target in block '${blockId}': must be a string`)
}
})
}
// Parse condition connections
if (connections.conditions) {
if (typeof connections.conditions !== 'object') {
errors.push(`Invalid conditions in block '${blockId}': must be an object`)
} else {
Object.entries(connections.conditions).forEach(([conditionId, targets]) => {
const targetArray = Array.isArray(targets) ? targets : [targets]
targetArray.forEach((target) => {
if (typeof target === 'string') {
// Create condition handle based on block type and condition ID
const sourceHandle = createConditionHandle(blockId, conditionId, blockType)
edges.push(createEdge(blockId, target, sourceHandle, 'target'))
} else {
errors.push(
`Invalid condition target for '${conditionId}' in block '${blockId}': must be a string`
)
}
})
})
}
}
// Parse loop connections
if (connections.loop) {
if (typeof connections.loop !== 'object') {
errors.push(`Invalid loop connections in block '${blockId}': must be an object`)
} else {
if (connections.loop.start) {
const targets = Array.isArray(connections.loop.start)
? connections.loop.start
: [connections.loop.start]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'loop-start-source', 'target'))
} else {
errors.push(`Invalid loop start target in block '${blockId}': must be a string`)
}
})
}
if (connections.loop.end) {
const targets = Array.isArray(connections.loop.end)
? connections.loop.end
: [connections.loop.end]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'loop-end-source', 'target'))
} else {
errors.push(`Invalid loop end target in block '${blockId}': must be a string`)
}
})
}
}
}
// Parse parallel connections
if (connections.parallel) {
if (typeof connections.parallel !== 'object') {
errors.push(`Invalid parallel connections in block '${blockId}': must be an object`)
} else {
if (connections.parallel.start) {
const targets = Array.isArray(connections.parallel.start)
? connections.parallel.start
: [connections.parallel.start]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'parallel-start-source', 'target'))
} else {
errors.push(`Invalid parallel start target in block '${blockId}': must be a string`)
}
})
}
if (connections.parallel.end) {
const targets = Array.isArray(connections.parallel.end)
? connections.parallel.end
: [connections.parallel.end]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'parallel-end-source', 'target'))
} else {
errors.push(`Invalid parallel end target in block '${blockId}': must be a string`)
}
})
}
}
}
// Parse direct handle formats (alternative to nested format)
// This allows using 'loop-start-source' directly instead of 'loop.start'
if (connections['loop-start-source']) {
const targets = Array.isArray(connections['loop-start-source'])
? connections['loop-start-source']
: [connections['loop-start-source']]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'loop-start-source', 'target'))
} else {
errors.push(`Invalid loop-start-source target in block '${blockId}': must be a string`)
}
})
}
if (connections['loop-end-source']) {
const targets = Array.isArray(connections['loop-end-source'])
? connections['loop-end-source']
: [connections['loop-end-source']]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'loop-end-source', 'target'))
} else {
errors.push(`Invalid loop-end-source target in block '${blockId}': must be a string`)
}
})
}
if (connections['parallel-start-source']) {
const targets = Array.isArray(connections['parallel-start-source'])
? connections['parallel-start-source']
: [connections['parallel-start-source']]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'parallel-start-source', 'target'))
} else {
errors.push(`Invalid parallel-start-source target in block '${blockId}': must be a string`)
}
})
}
if (connections['parallel-end-source']) {
const targets = Array.isArray(connections['parallel-end-source'])
? connections['parallel-end-source']
: [connections['parallel-end-source']]
targets.forEach((target) => {
if (typeof target === 'string') {
edges.push(createEdge(blockId, target, 'parallel-end-source', 'target'))
} else {
errors.push(`Invalid parallel-end-source target in block '${blockId}': must be a string`)
}
})
}
}
function parseLegacyOutgoingConnections(
blockId: string,
outgoing: Array<{ target: string; sourceHandle?: string; targetHandle?: string }>,
edges: ImportedEdge[],
errors: string[],
warnings: string[]
) {
warnings.push(
`Block '${blockId}' uses legacy connection format - consider upgrading to the new grouped format`
)
outgoing.forEach((connection) => {
if (!connection.target) {
errors.push(`Missing target in outgoing connection for block '${blockId}'`)
return
}
edges.push(
createEdge(
blockId,
connection.target,
connection.sourceHandle || 'source',
connection.targetHandle || 'target'
)
)
})
}
function createEdge(
source: string,
target: string,
sourceHandle: string,
targetHandle: string
): ImportedEdge {
return {
id: uuidv4(),
source,
target,
sourceHandle,
targetHandle,
type: 'workflowEdge',
}
}
function createConditionHandle(blockId: string, conditionId: string, blockType?: string): string {
// For condition blocks, create the handle format that the system expects
if (blockType === 'condition') {
// Map semantic condition IDs to the internal format the system expects
const actualConditionId = `${blockId}-${conditionId}`
return `${EDGE.CONDITION_PREFIX}${actualConditionId}`
}
// For other blocks that might have conditions, use a more explicit format
return `${EDGE.CONDITION_PREFIX}${blockId}-${conditionId}`
}
function extractConditionId(sourceHandle: string): string {
// Extract condition ID from handle like "condition-blockId-semantic-key"
// Example: "condition-e23e6318-bcdc-4572-a76b-5015e3950121-else-if-1752111795510"
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return sourceHandle
}
// Remove condition prefix
const withoutPrefix = sourceHandle.substring(EDGE.CONDITION_PREFIX.length)
// Special case: check if this ends with "-else" (the auto-added else condition)
if (withoutPrefix.endsWith('-else')) {
return 'else'
}
// Find the first UUID pattern (36 characters with 4 hyphens in specific positions)
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i
const match = withoutPrefix.match(uuidRegex)
if (match) {
// Extract everything after the UUID - return raw ID for further processing
return match[1]
}
// Fallback for legacy format or simpler cases
const parts = sourceHandle.split('-')
if (parts.length >= 2) {
return parts[parts.length - 1]
}
return sourceHandle
}

View File

@@ -1,175 +0,0 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import { useWorkflowRegistry } from '../registry/store'
import { useSubBlockStore } from '../subblock/store'
import { useWorkflowStore } from '../workflow/store'
const logger = createLogger('WorkflowYamlStore')
interface WorkflowYamlState {
yaml: string
lastGenerated?: number
}
interface WorkflowYamlActions {
generateYaml: () => Promise<void>
getYaml: () => Promise<string>
refreshYaml: () => void
}
type WorkflowYamlStore = WorkflowYamlState & WorkflowYamlActions
/**
* Get subblock values organized by block for the shared utility
*/
function getSubBlockValues() {
const workflowState = useWorkflowStore.getState()
const subBlockStore = useSubBlockStore.getState()
const subBlockValues: Record<string, Record<string, any>> = {}
Object.entries(workflowState.blocks).forEach(([blockId]) => {
subBlockValues[blockId] = {}
// Get all subblock values for this block
Object.keys(workflowState.blocks[blockId].subBlocks || {}).forEach((subBlockId) => {
const value = subBlockStore.getValue(blockId, subBlockId)
if (value !== undefined) {
subBlockValues[blockId][subBlockId] = value
}
})
})
return subBlockValues
}
// Track if subscriptions have been initialized
let subscriptionsInitialized = false
// Track timeout IDs for cleanup
let workflowRefreshTimeoutId: NodeJS.Timeout | null = null
let subBlockRefreshTimeoutId: NodeJS.Timeout | null = null
// Initialize subscriptions lazily
function initializeSubscriptions() {
if (subscriptionsInitialized) return
subscriptionsInitialized = true
// 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
// Clear existing timeout to properly debounce
if (workflowRefreshTimeoutId) {
clearTimeout(workflowRefreshTimeoutId)
}
// Debounce the refresh to avoid excessive updates
const refreshYaml = useWorkflowYamlStore.getState().refreshYaml
workflowRefreshTimeoutId = setTimeout(() => {
refreshYaml()
workflowRefreshTimeoutId = null
}, 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
// Clear existing timeout to properly debounce
if (subBlockRefreshTimeoutId) {
clearTimeout(subBlockRefreshTimeoutId)
}
const refreshYaml = useWorkflowYamlStore.getState().refreshYaml
subBlockRefreshTimeoutId = setTimeout(() => {
refreshYaml()
subBlockRefreshTimeoutId = null
}, 100)
}
})
}
export const useWorkflowYamlStore = create<WorkflowYamlStore>()(
devtools(
(set, get) => ({
yaml: '',
lastGenerated: undefined,
generateYaml: async () => {
// Initialize subscriptions on first use
initializeSubscriptions()
// Get the active workflow ID from registry
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
logger.warn('No active workflow to generate YAML for')
return
}
// Call the new database-based export endpoint
const response = await fetch(`/api/workflows/yaml/export?workflowId=${activeWorkflowId}`)
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Failed to generate YAML:', errorData?.error || response.statusText)
return
}
const result = await response.json()
if (result.success && result.yaml) {
set({
yaml: result.yaml,
lastGenerated: Date.now(),
})
} else {
logger.error('Failed to generate YAML:', result.error)
}
},
getYaml: async () => {
// Initialize subscriptions on first use
initializeSubscriptions()
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) {
await get().generateYaml()
return get().yaml
}
return yaml
},
refreshYaml: () => {
get().generateYaml()
},
}),
{
name: 'workflow-yaml-store',
}
)
)