improvement(loops): added vars to loop-input

This commit is contained in:
Emir Karabeg
2025-03-30 03:26:49 -07:00
parent db8b5642a0
commit 89ba7fa3c2
5 changed files with 381 additions and 42 deletions
@@ -8,6 +8,7 @@ import { NodeProps } from 'reactflow'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -28,6 +29,11 @@ export function LoopInput({ id }: NodeProps) {
const [open, setOpen] = useState(false)
const editorRef = useRef<HTMLDivElement | null>(null)
// State for tag dropdown
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
// Initialize editor value from the store
useEffect(() => {
if (loopType === 'forEach' && loop?.forEachItems) {
@@ -79,10 +85,27 @@ export function LoopInput({ id }: NodeProps) {
}
}
// Handle tag selection
const handleTagSelect = (newValue: string) => {
setEditorValue(newValue)
updateLoopForEachItems(loopId, newValue)
setShowTags(false)
}
const handleEditorChange = (value: string) => {
// Always set the editor value to exactly what the user typed
setEditorValue(value)
// Get current cursor position (approximation for editor)
const textArea = editorRef.current?.querySelector('textarea')
if (textArea) {
setCursorPosition(textArea.selectionStart || 0)
// Check for tag trigger
const tagTrigger = checkTagTrigger(value, textArea.selectionStart || 0)
setShowTags(tagTrigger.show)
}
// Save the items to the store for forEach loops
if (loopType === 'forEach') {
// Pass the exact string to preserve formatting
@@ -90,6 +113,52 @@ export function LoopInput({ id }: NodeProps) {
}
}
// Handle editor focus
const handleEditorFocus = () => {
// Reset tag dropdown state
setShowTags(false)
setActiveSourceBlockId(null)
}
// Handle editor blur
const handleEditorBlur = () => {
// We don't immediately hide the tag dropdown to allow clicking on it
setTimeout(() => {
if (!document.activeElement?.closest('.tag-dropdown')) {
setShowTags(false)
}
}, 100)
}
// Add and remove keyboard event listener for editor
useEffect(() => {
const textArea = editorRef.current?.querySelector('textarea')
if (!textArea) return
const handleKeyboardEvent = (e: KeyboardEvent) => {
// Update cursor position when key is pressed in editor
setCursorPosition(textArea.selectionStart || 0)
// Check for tag trigger
const tagTrigger = checkTagTrigger(editorValue, textArea.selectionStart || 0)
if (e.key === 'Escape') {
setShowTags(false)
} else if (e.key === '<') {
// Show tags dropdown when '<' is typed
setShowTags(true)
} else if (e.key === '>') {
// Hide tags dropdown when '>' is typed
setShowTags(false)
} else {
setShowTags(tagTrigger.show)
}
}
textArea.addEventListener('keyup', handleKeyboardEvent)
return () => textArea.removeEventListener('keyup', handleKeyboardEvent)
}, [editorValue, editorRef])
// Determine label based on loop type
const getLabel = () => {
switch (loopType) {
@@ -149,27 +218,45 @@ export function LoopInput({ id }: NodeProps) {
/>
</div>
) : (
// Code editor for 'forEach' loops
<div
className="relative min-h-[80px] rounded-md bg-background font-mono text-sm px-3 pt-2 pb-3 border border-input"
ref={editorRef}
>
{editorValue === '' && (
<div className="absolute top-[8.5px] left-3 text-muted-foreground/50 pointer-events-none select-none">
{getPlaceholder()}
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
// Code editor for 'forEach' loops with tag dropdown support
<div className="relative">
<div
className="relative min-h-[80px] rounded-md bg-background font-mono text-sm px-3 pt-2 pb-3 border border-input"
ref={editorRef}
>
{editorValue === '' && (
<div className="absolute top-[8.5px] left-3 text-muted-foreground/50 pointer-events-none select-none">
{getPlaceholder()}
</div>
)}
<Editor
value={editorValue}
onValueChange={handleEditorChange}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={0}
style={{
fontFamily: 'monospace',
lineHeight: '21px',
}}
className="focus:outline-none w-full"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
/>
</div>
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={id}
activeSourceBlockId={activeSourceBlockId}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
className="focus:outline-none w-full"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap"
className="w-[calc(100%)] tag-dropdown"
style={{ top: 'calc(100% + 4px)' }}
/>
</div>
)}
+1 -1
View File
@@ -45,7 +45,7 @@ export class Executor {
this.validateWorkflow()
this.workflowInput = workflowInput || {}
this.loopManager = new LoopManager(workflow.loops || {})
this.loopManager = new LoopManager(workflow.loops || {}, 5, workflowVariables)
this.resolver = new InputResolver(workflow, environmentVariables, workflowVariables, this.loopManager)
this.pathTracker = new PathTracker(workflow)
+244 -14
View File
@@ -5,10 +5,15 @@ import { ExecutionContext } from './types'
* Manages loop detection, iteration limits, and state resets.
*/
export class LoopManager {
private workflowVariables: Record<string, any>;
constructor(
private loops: Record<string, SerializedLoop>,
private defaultIterations: number = 5
) {}
private defaultIterations: number = 5,
workflowVariables: Record<string, any> = {}
) {
this.workflowVariables = workflowVariables;
}
/**
* Processes all loops and checks if any need to be iterated.
@@ -247,8 +252,161 @@ export class LoopManager {
return [];
}
// First check if it's valid JSON (array or object)
if (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{')) {
// Import the resolver to handle variable and block references
let InputResolver;
try {
InputResolver = (await import('./resolver')).InputResolver;
} catch (importError) {
console.error(`Failed to import InputResolver: ${importError}`);
return [];
}
// Create a temporary block for the resolver to use
const dummyBlock = {
id: `temp-${loopId}`,
position: { x: 0, y: 0 },
config: {
tool: 'loop',
params: {
items: trimmedExpression
}
},
inputs: {},
outputs: {},
enabled: true
};
// Create a resolver instance
const resolver = new InputResolver(
context.workflow || { version: '1.0', blocks: [], connections: [], loops: {} },
context.environmentVariables,
this.workflowVariables,
this
);
// Special handling for tag references (single variable tag enclosed in < >)
if (/^<[^>]+>$/.test(trimmedExpression)) {
// For a single tag reference, try to resolve it directly
const resolved = resolver.resolveBlockReferences(
trimmedExpression,
context,
dummyBlock as any
);
// If the resolved value has changed, it was successfully resolved
if (resolved !== trimmedExpression) {
try {
// Try to parse as JSON if it looks like JSON
if ((resolved.startsWith('[') && resolved.endsWith(']')) ||
(resolved.startsWith('{') && resolved.endsWith('}'))) {
return JSON.parse(resolved);
} else if (resolved.trim() !== '') {
// If it's not JSON but has content, return as a single-item array
return [resolved];
}
} catch (jsonError) {
// If JSON parsing fails, just return as a single item
return [resolved];
}
}
}
// Check if the string contains variables tags (with < and >)
const containsVariableTags = trimmedExpression.includes('<') && trimmedExpression.includes('>');
// Handle arrays with variable references: [<var1>, <var2>, 3, "text"]
if (containsVariableTags && trimmedExpression.startsWith('[') && trimmedExpression.endsWith(']')) {
try {
// Extract the items but preserve the array structure
const itemsStr = trimmedExpression.substring(1, trimmedExpression.length - 1);
// Use a simple but effective split that respects JSON structure
const items = this.splitArrayItems(itemsStr);
// Process each item
const result = [];
for (const item of items) {
const trimmedItem = item.trim();
// If it's a variable tag, resolve it
if (trimmedItem.includes('<') && trimmedItem.includes('>')) {
// Resolve any variable/block references in this item
const resolvedItem = resolver.resolveBlockReferences(
trimmedItem,
context,
dummyBlock as any
);
// If it's still the same as the original, it wasn't resolved
if (resolvedItem === trimmedItem) {
// Keep as is for now - it may be resolvable during iteration
result.push(trimmedItem);
} else {
// Successfully resolved - parse as JSON if possible
try {
if ((resolvedItem.startsWith('[') && resolvedItem.endsWith(']')) ||
(resolvedItem.startsWith('{') && resolvedItem.endsWith('}'))) {
result.push(JSON.parse(resolvedItem));
} else {
// Not JSON, use the primitive value
// Try to convert to appropriate type
if (resolvedItem === 'true') result.push(true);
else if (resolvedItem === 'false') result.push(false);
else if (resolvedItem === 'null') result.push(null);
else if (!isNaN(Number(resolvedItem))) result.push(Number(resolvedItem));
else result.push(resolvedItem);
}
} catch (jsonError) {
// Not valid JSON, use as is
result.push(resolvedItem);
}
}
} else {
// For non-variable items, parse as JSON
try {
result.push(JSON.parse(trimmedItem));
} catch (jsonError) {
// If not valid JSON, add as a string
result.push(trimmedItem);
}
}
}
return result;
} catch (e) {
console.error(`Error processing array with variables for loop ${loopId}:`, e);
// Fall back to treating the whole thing as a string array
return [trimmedExpression];
}
}
// Handle objects with variable references: {"key": <var>, "key2": "value"}
if (containsVariableTags && trimmedExpression.startsWith('{') && trimmedExpression.endsWith('}')) {
try {
// For objects, try a different approach - first resolve all variables in the string
const fullyResolvedStr = resolver.resolveBlockReferences(
trimmedExpression,
context,
dummyBlock as any
);
// Try to parse the fully resolved string as JSON
try {
return JSON.parse(fullyResolvedStr);
} catch (jsonError) {
console.error(`Error parsing resolved object for loop ${loopId}:`, jsonError);
// If that fails, return as a string
return [fullyResolvedStr];
}
} catch (e) {
console.error(`Error processing object with variables for loop ${loopId}:`, e);
return [trimmedExpression];
}
}
// If it doesn't contain variable tags, or isn't an array/object with tags,
// continue with standard JSON parsing
if (!containsVariableTags && (trimmedExpression.startsWith('[') || trimmedExpression.startsWith('{'))) {
try {
// Try to parse as JSON first
return JSON.parse(trimmedExpression);
@@ -259,16 +417,20 @@ export class LoopManager {
}
// If not valid JSON or JSON parsing failed, try to evaluate as an expression
const result = new Function('context', `return ${loop.forEachItems}`)(context);
// If the result is an array or object, return it
if (Array.isArray(result) || (typeof result === 'object' && result !== null)) {
return result;
}
// If it's a primitive, wrap it in an array
if (result !== undefined) {
return [result];
try {
const result = new Function('context', `return ${loop.forEachItems}`)(context);
// If the result is an array or object, return it
if (Array.isArray(result) || (typeof result === 'object' && result !== null)) {
return result;
}
// If it's a primitive, wrap it in an array
if (result !== undefined) {
return [result];
}
} catch (evalError) {
console.error(`Error evaluating expression for loop ${loopId}:`, evalError);
}
return [];
@@ -296,6 +458,74 @@ export class LoopManager {
return [];
}
/**
* Splits array items respecting nested structures like objects and arrays.
* This is a more robust way to split array items than simply splitting by commas.
*
* @param str - The string containing array items (without the outer brackets)
* @returns Array of item strings
*/
private splitArrayItems(str: string): string[] {
const result: string[] = [];
let currentItem = '';
let depth = 0;
let inString = false;
let escapeNext = false;
for (let i = 0; i < str.length; i++) {
const char = str[i];
// Handle escape sequences in strings
if (inString && char === '\\' && !escapeNext) {
escapeNext = true;
currentItem += char;
continue;
}
// If we just processed an escape, reset the flag
if (escapeNext) {
escapeNext = false;
currentItem += char;
continue;
}
// Handle string boundaries
if (char === '"' || char === "'") {
inString = !inString;
currentItem += char;
continue;
}
// Skip processing special characters if we're in a string
if (inString) {
currentItem += char;
continue;
}
// Handle nested structures
if (char === '[' || char === '{') {
depth++;
currentItem += char;
} else if (char === ']' || char === '}') {
depth--;
currentItem += char;
} else if (char === ',' && depth === 0) {
// Only split on commas at the top level
result.push(currentItem);
currentItem = '';
} else {
currentItem += char;
}
}
// Add the last item if there is one
if (currentItem.trim()) {
result.push(currentItem);
}
return result;
}
/**
* Finds the entry block for a loop (the one that should be executed first).
* Typically the block with the fewest incoming connections.
+20 -1
View File
@@ -195,7 +195,26 @@ export class InputResolver {
// Get the current item directly from the items array at the current index
if (Array.isArray(items) && index >= 0 && index < items.length) {
const currentItem = items[index];
let currentItem = items[index];
// Special handling for variable references stored as strings in loop items
if (typeof currentItem === 'string' && currentItem.startsWith('<') && currentItem.endsWith('>') &&
currentItem.includes('variable.')) {
// This is a variable reference stored as a string in the array
// Try to resolve it using the same variable resolution logic
const resolvedVar = this.resolveVariableReferences(currentItem);
// If it was resolved (changed), update the current item
if (resolvedVar !== currentItem) {
currentItem = resolvedVar;
// Try to convert to appropriate primitive type if possible
if (resolvedVar === 'true') currentItem = true;
else if (resolvedVar === 'false') currentItem = false;
else if (resolvedVar === 'null') currentItem = null;
else if (!isNaN(Number(resolvedVar))) currentItem = Number(resolvedVar);
}
}
// Format the value based on type
if (currentItem !== undefined) {
+9 -6
View File
@@ -563,19 +563,22 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
},
updateLoopForEachItems: (loopId: string, items: string) => {
let parsedItems: any = items;
// Don't try to parse JSON if the string contains variable tags <...>
const containsVariableTags = items.includes('<') && items.includes('>')
// Try to parse the string as JSON if it looks like JSON
if (typeof items === 'string' &&
let parsedItems = items;
// Only try to parse as JSON if it doesn't contain variable tags
if (!containsVariableTags &&
typeof items === 'string' &&
((items.trim().startsWith('[') && items.trim().endsWith(']')) ||
(items.trim().startsWith('{') && items.trim().endsWith('}')))
) {
try {
// First try to parse to validate it's valid JSON
const parsed = JSON.parse(items);
JSON.parse(items);
// If parsing succeeds, store the original string to preserve formatting
// This way we keep the user's exact formatting (spacing, line breaks, etc.)
parsedItems = items;
} catch (e) {
// If parsing fails, keep it as a string expression
@@ -586,7 +589,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
const newState = {
blocks: { ...get().blocks },
edges: [...get().edges],
edges: [...get().edges ],
loops: {
...get().loops,
[loopId]: {