mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
* fix(stores): moved subblock value to a separate store; execution currently doesn't work * fix(execution): resolved merging stores * fix(stores): deletion crash fix * fix(stores): dropdown re-render
This commit is contained in:
@@ -16,7 +16,7 @@ interface DropdownProps {
|
||||
}
|
||||
|
||||
export function Dropdown({ options, defaultValue, blockId, subBlockId }: DropdownProps) {
|
||||
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
|
||||
const [value, setValue] = useSubBlockValue(blockId, subBlockId, true)
|
||||
|
||||
// Set the value to the first option if it's not set
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
|
||||
|
||||
export function useSubBlockValue<T = any>(
|
||||
blockId: string,
|
||||
subBlockId: string
|
||||
subBlockId: string,
|
||||
triggerWorkflowUpdate: boolean = false
|
||||
): readonly [T | null, (value: T) => void] {
|
||||
const value = useWorkflowStore(
|
||||
// Get initial value from workflow store
|
||||
const initialValue = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => state.blocks[blockId]?.subBlocks[subBlockId]?.value ?? null,
|
||||
[blockId, subBlockId]
|
||||
)
|
||||
)
|
||||
|
||||
const updateSubBlock = useWorkflowStore((state) => state.updateSubBlock)
|
||||
// Get value and setter from subblock store
|
||||
const value = useSubBlockStore(
|
||||
useCallback(
|
||||
(state) => state.getValue(blockId, subBlockId) ?? initialValue,
|
||||
[blockId, subBlockId, initialValue]
|
||||
)
|
||||
)
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue: T) => {
|
||||
updateSubBlock(blockId, subBlockId, newValue as any)
|
||||
useSubBlockStore.getState().setValue(blockId, subBlockId, newValue)
|
||||
if (triggerWorkflowUpdate) {
|
||||
useWorkflowStore.getState().triggerUpdate()
|
||||
}
|
||||
},
|
||||
[blockId, subBlockId, updateSubBlock]
|
||||
[blockId, subBlockId, triggerWorkflowUpdate]
|
||||
)
|
||||
|
||||
return [value as T | null, setValue] as const
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { mergeSubblockState } from '@/stores/workflow/utils'
|
||||
import { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
||||
import { ActionBar } from './components/action-bar/action-bar'
|
||||
import { ConnectionBlocks } from './components/connection-blocks/connection-blocks'
|
||||
@@ -34,6 +35,7 @@ export function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockPro
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
|
||||
// Workflow store selectors
|
||||
const lastUpdate = useWorkflowStore((state) => state.lastUpdate)
|
||||
const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true)
|
||||
const horizontalHandles = useWorkflowStore(
|
||||
(state) => state.blocks[id]?.horizontalHandles ?? false
|
||||
@@ -100,7 +102,7 @@ export function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockPro
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}
|
||||
}, [id, blockHeight, updateBlockHeight, updateNodeInternals])
|
||||
}, [id, blockHeight, updateBlockHeight, updateNodeInternals, lastUpdate])
|
||||
|
||||
// SubBlock layout management
|
||||
function groupSubBlocks(subBlocks: SubBlockConfig[], blockId: string) {
|
||||
@@ -108,6 +110,10 @@ export function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockPro
|
||||
let currentRow: SubBlockConfig[] = []
|
||||
let currentRowWidth = 0
|
||||
|
||||
// Get merged state for this block
|
||||
const blocks = useWorkflowStore.getState().blocks
|
||||
const mergedState = mergeSubblockState(blocks, blockId)[blockId]
|
||||
|
||||
// Filter visible blocks and those that meet their conditions
|
||||
const visibleSubBlocks = subBlocks.filter((block) => {
|
||||
if (block.hidden) return false
|
||||
@@ -115,11 +121,10 @@ export function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockPro
|
||||
// If there's no condition, the block should be shown
|
||||
if (!block.condition) return true
|
||||
|
||||
// Get the values of the fields this block depends on
|
||||
const fieldValue =
|
||||
useWorkflowStore.getState().blocks[blockId]?.subBlocks[block.condition.field]?.value
|
||||
// Get the values of the fields this block depends on from merged state
|
||||
const fieldValue = mergedState?.subBlocks[block.condition.field]?.value
|
||||
const andFieldValue = block.condition.and
|
||||
? useWorkflowStore.getState().blocks[blockId]?.subBlocks[block.condition.and.field]?.value
|
||||
? mergedState?.subBlocks[block.condition.and.field]?.value
|
||||
: undefined
|
||||
|
||||
// Check both conditions if 'and' is present
|
||||
|
||||
@@ -323,9 +323,9 @@ function WorkflowContent() {
|
||||
}, [selectedEdgeId, removeEdge])
|
||||
|
||||
// Initialize state logging
|
||||
useEffect(() => {
|
||||
initializeStateLogger()
|
||||
}, [])
|
||||
// useEffect(() => {
|
||||
// initializeStateLogger()
|
||||
// }, [])
|
||||
|
||||
if (!isInitialized) return null
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflow/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflow/store'
|
||||
import { useSubBlockStore } from '@/stores/workflow/subblock/store'
|
||||
import { mergeSubblockState } from '@/stores/workflow/utils'
|
||||
import { Executor } from '@/executor'
|
||||
import { ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -27,18 +29,32 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract existing block states
|
||||
const currentBlockStates = Object.entries(blocks).reduce(
|
||||
// Use the mergeSubblockState utility to get all block states
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
const currentBlockStates = Object.entries(mergedStates).reduce(
|
||||
(acc, [id, block]) => {
|
||||
const responseValue = block.subBlocks?.response?.value
|
||||
if (responseValue !== undefined) {
|
||||
acc[id] = { response: responseValue }
|
||||
}
|
||||
acc[id] = Object.entries(block.subBlocks).reduce(
|
||||
(subAcc, [key, subBlock]) => {
|
||||
subAcc[key] = subBlock.value
|
||||
return subAcc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Debug logging
|
||||
console.group('Workflow Execution State')
|
||||
console.log('Block Configurations:', blocks)
|
||||
console.log(
|
||||
'SubBlock Store Values:',
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
)
|
||||
console.log('Merged Block States for Execution:', currentBlockStates)
|
||||
console.groupEnd()
|
||||
|
||||
// Get environment variables
|
||||
const envVars = getAllVariables()
|
||||
const envVarValues = Object.entries(envVars).reduce(
|
||||
@@ -50,7 +66,7 @@ export function useWorkflowExecution() {
|
||||
)
|
||||
|
||||
// Execute workflow
|
||||
const workflow = new Serializer().serializeWorkflow(blocks, edges, loops)
|
||||
const workflow = new Serializer().serializeWorkflow(mergedStates, edges, loops)
|
||||
const executor = new Executor(workflow, currentBlockStates, envVarValues)
|
||||
const result = await executor.execute(activeWorkflowId)
|
||||
|
||||
@@ -65,6 +81,7 @@ export function useWorkflowExecution() {
|
||||
activeWorkflowId
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error('Workflow Execution Error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setExecutionResult({
|
||||
success: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { useSubBlockStore } from '../subblock/store'
|
||||
import { WorkflowMetadata, WorkflowRegistry } from './types'
|
||||
import { generateUniqueName } from './utils'
|
||||
|
||||
@@ -36,10 +37,14 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
)
|
||||
}
|
||||
|
||||
// Load new workflow state
|
||||
// Load workflow state
|
||||
const savedState = localStorage.getItem(`workflow-${id}`)
|
||||
if (savedState) {
|
||||
const { blocks, edges, history, loops } = JSON.parse(savedState)
|
||||
|
||||
// Initialize subblock store with workflow values
|
||||
useSubBlockStore.getState().initializeFromWorkflow(id, blocks)
|
||||
|
||||
useWorkflowStore.setState({
|
||||
blocks,
|
||||
edges,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { devtools } from 'zustand/middleware'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { resolveOutputType } from '@/blocks/utils'
|
||||
import { WorkflowStoreWithHistory, pushHistory, withHistory } from './middleware'
|
||||
import { useWorkflowRegistry } from './registry/store'
|
||||
import { useSubBlockStore } from './subblock/store'
|
||||
import { Loop, Position, SubBlockState } from './types'
|
||||
import { detectCycle } from './utils'
|
||||
|
||||
@@ -33,75 +35,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
canRedo: () => false,
|
||||
revertToHistoryState: () => {},
|
||||
|
||||
updateSubBlock: (blockId: string, subBlockId: string, value: any) => {
|
||||
set((state) => {
|
||||
const block = state.blocks[blockId]
|
||||
if (!block) return state
|
||||
|
||||
// Handle different value types appropriately
|
||||
const processedValue = Array.isArray(value)
|
||||
? value
|
||||
: block.subBlocks[subBlockId]?.type === 'slider'
|
||||
? Number(value) // Convert slider values to numbers
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)
|
||||
|
||||
// Only attempt JSON parsing for agent responseFormat validation
|
||||
if (
|
||||
block.type === 'agent' &&
|
||||
subBlockId === 'responseFormat' &&
|
||||
typeof processedValue === 'string'
|
||||
) {
|
||||
try {
|
||||
// Parse the input string to validate JSON but keep original string value
|
||||
const parsed = JSON.parse(processedValue)
|
||||
|
||||
// Simple validation of required schema structure
|
||||
if (!parsed.fields || !Array.isArray(parsed.fields)) {
|
||||
console.error('Validation failed: missing fields array')
|
||||
throw new Error('Response format must have a fields array')
|
||||
}
|
||||
|
||||
for (const field of parsed.fields) {
|
||||
if (!field.name || !field.type) {
|
||||
console.error('Validation failed: field missing name or type', field)
|
||||
throw new Error('Each field must have a name and type')
|
||||
}
|
||||
if (!['string', 'number', 'boolean', 'array', 'object'].includes(field.type)) {
|
||||
console.error('Validation failed: invalid field type', field)
|
||||
throw new Error(
|
||||
`Invalid type "${field.type}" - must be one of: string, number, boolean, array, object`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't modify the value, keep it as the original string
|
||||
} catch (error: any) {
|
||||
console.error('responseFormat validation error:', error)
|
||||
throw new Error(`Invalid JSON schema: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: {
|
||||
...state.blocks,
|
||||
[blockId]: {
|
||||
...block,
|
||||
subBlocks: {
|
||||
...block.subBlocks,
|
||||
[subBlockId]: {
|
||||
...block.subBlocks[subBlockId],
|
||||
value: processedValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
get().updateLastSaved()
|
||||
},
|
||||
|
||||
addBlock: (id: string, type: string, name: string, position: Position) => {
|
||||
const blockConfig = getBlock(type)
|
||||
if (!blockConfig) return
|
||||
@@ -158,20 +91,38 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
},
|
||||
|
||||
removeBlock: (id: string) => {
|
||||
// First, clean up any subblock values for this block
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id),
|
||||
loops: { ...get().loops },
|
||||
}
|
||||
|
||||
// Remove the block from any loops that contain it
|
||||
// Clean up subblock values before removing the block
|
||||
if (activeWorkflowId) {
|
||||
const updatedWorkflowValues = {
|
||||
...(subBlockStore.workflowValues[activeWorkflowId] || {}),
|
||||
}
|
||||
delete updatedWorkflowValues[id]
|
||||
|
||||
// Update subblock store
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Clean up loops
|
||||
Object.entries(newState.loops).forEach(([loopId, loop]) => {
|
||||
if (loop.nodes.includes(id)) {
|
||||
// If the loop would only have 1 or 0 nodes after removal, delete the loop
|
||||
if (loop.nodes.length <= 2) {
|
||||
delete newState.loops[loopId]
|
||||
} else {
|
||||
// Otherwise, just remove the node from the loop
|
||||
newState.loops[loopId] = {
|
||||
...loop,
|
||||
nodes: loop.nodes.filter((nodeId) => nodeId !== id),
|
||||
@@ -180,7 +131,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
}
|
||||
})
|
||||
|
||||
// Delete the block itself
|
||||
// Delete the block last
|
||||
delete newState.blocks[id]
|
||||
|
||||
set(newState)
|
||||
@@ -450,6 +401,13 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
pushHistory(set, get, newState, 'Update loop max iterations')
|
||||
get().updateLastSaved()
|
||||
},
|
||||
|
||||
triggerUpdate: () => {
|
||||
set((state) => ({
|
||||
...state,
|
||||
lastUpdate: Date.now(),
|
||||
}))
|
||||
},
|
||||
})),
|
||||
{ name: 'workflow-store' }
|
||||
)
|
||||
|
||||
123
stores/workflow/subblock/store.ts
Normal file
123
stores/workflow/subblock/store.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowRegistry } from '../registry/store'
|
||||
|
||||
interface SubBlockState {
|
||||
workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID
|
||||
}
|
||||
|
||||
interface SubBlockStore extends SubBlockState {
|
||||
setValue: (blockId: string, subBlockId: string, value: any) => void
|
||||
getValue: (blockId: string, subBlockId: string) => any
|
||||
clear: () => void
|
||||
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => void
|
||||
}
|
||||
|
||||
export const useSubBlockStore = create<SubBlockStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
workflowValues: {},
|
||||
|
||||
setValue: (blockId: string, subBlockId: string, value: any) => {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
set((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
[blockId]: {
|
||||
...state.workflowValues[activeWorkflowId]?.[blockId],
|
||||
[subBlockId]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Persist to localStorage for backup
|
||||
const storageKey = `subblock-values-${activeWorkflowId}`
|
||||
const currentValues = get().workflowValues[activeWorkflowId] || {}
|
||||
localStorage.setItem(storageKey, JSON.stringify(currentValues))
|
||||
},
|
||||
|
||||
getValue: (blockId: string, subBlockId: string) => {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return null
|
||||
|
||||
return get().workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
set((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {},
|
||||
},
|
||||
}))
|
||||
|
||||
localStorage.removeItem(`subblock-values-${activeWorkflowId}`)
|
||||
},
|
||||
|
||||
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => {
|
||||
// First, try to load from localStorage
|
||||
const storageKey = `subblock-values-${workflowId}`
|
||||
const savedValues = localStorage.getItem(storageKey)
|
||||
|
||||
if (savedValues) {
|
||||
const parsedValues = JSON.parse(savedValues)
|
||||
set((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: parsedValues,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// If no saved values, initialize from blocks
|
||||
const values: Record<string, Record<string, any>> = {}
|
||||
Object.entries(blocks).forEach(([blockId, block]) => {
|
||||
values[blockId] = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
values[blockId][subBlockId] = (subBlock as SubBlockConfig).value
|
||||
})
|
||||
})
|
||||
|
||||
set((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[workflowId]: values,
|
||||
},
|
||||
}))
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(storageKey, JSON.stringify(values))
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'subblock-store',
|
||||
partialize: (state) => ({ workflowValues: state.workflowValues }),
|
||||
// Use default storage
|
||||
storage: {
|
||||
getItem: (name) => {
|
||||
const value = localStorage.getItem(name)
|
||||
return value ? JSON.parse(value) : null
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
localStorage.setItem(name, JSON.stringify(value))
|
||||
},
|
||||
removeItem: (name) => {
|
||||
localStorage.removeItem(name)
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
{ name: 'subblock-store' }
|
||||
)
|
||||
)
|
||||
@@ -36,12 +36,12 @@ export interface WorkflowState {
|
||||
edges: Edge[]
|
||||
lastSaved?: number
|
||||
loops: Record<string, Loop>
|
||||
lastUpdate?: number
|
||||
}
|
||||
|
||||
export interface WorkflowActions {
|
||||
addBlock: (id: string, type: string, name: string, position: Position) => void
|
||||
updateBlockPosition: (id: string, position: Position) => void
|
||||
updateSubBlock: (blockId: string, subBlockId: string, subBlock: SubBlockState) => void
|
||||
removeBlock: (id: string) => void
|
||||
addEdge: (edge: Edge) => void
|
||||
removeEdge: (edgeId: string) => void
|
||||
@@ -54,6 +54,7 @@ export interface WorkflowActions {
|
||||
toggleBlockWide: (id: string) => void
|
||||
updateBlockHeight: (id: string, height: number) => void
|
||||
updateLoopMaxIterations: (loopId: string, maxIterations: number) => void
|
||||
triggerUpdate: () => void
|
||||
}
|
||||
|
||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
import { Edge } from 'reactflow'
|
||||
import { useSubBlockStore } from './subblock/store'
|
||||
import { BlockState, SubBlockState } from './types'
|
||||
|
||||
/**
|
||||
* Merges workflow block states with subblock values while maintaining block structure
|
||||
* @param blocks - Block configurations from workflow store
|
||||
* @param blockId - Optional specific block ID to merge (merges all if not provided)
|
||||
* @returns Merged block states with updated values
|
||||
*/
|
||||
export function mergeSubblockState(
|
||||
blocks: Record<string, BlockState>,
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
// Create a deep copy of the block's subBlocks to maintain structure
|
||||
const mergedSubBlocks = Object.entries(block.subBlocks).reduce(
|
||||
(subAcc, [subBlockId, subBlock]) => {
|
||||
// Get the stored value for this subblock
|
||||
const storedValue = useSubBlockStore.getState().getValue(id, subBlockId)
|
||||
|
||||
// Create a new subblock object with the same structure but updated value
|
||||
subAcc[subBlockId] = {
|
||||
...subBlock,
|
||||
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
|
||||
}
|
||||
|
||||
return subAcc
|
||||
},
|
||||
{} as Record<string, SubBlockState>
|
||||
)
|
||||
|
||||
// Return the full block state with updated subBlocks
|
||||
acc[id] = {
|
||||
...block,
|
||||
subBlocks: mergedSubBlocks,
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, BlockState>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a depth-first search to detect all cycles in the graph
|
||||
|
||||
Reference in New Issue
Block a user