mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(while, vars, wait): add while subflow, variables block, wait block (#1754)
* Add variables block * Add wait block * While loop v1 * While loop v1 * Do while loops * Copilot user input rerender fix * Fix while and dowhile * Vars block dropdown * While loop docs * Remove vars block coloring * Fix lint * Link docs to wait * Fix build fail
This commit is contained in:
committed by
GitHub
parent
ef5b6999ab
commit
aace3066aa
@@ -16,7 +16,7 @@ Blocks are the building components you connect together to create AI workflows.
|
||||
|
||||
## Core Block Types
|
||||
|
||||
Sim provides seven core block types that handle the essential functions of AI workflows:
|
||||
Sim provides essential block types that handle the core functions of AI workflows:
|
||||
|
||||
### Processing Blocks
|
||||
- **[Agent](/blocks/agent)** - Chat with AI models (OpenAI, Anthropic, Google, local models)
|
||||
@@ -28,6 +28,10 @@ Sim provides seven core block types that handle the essential functions of AI wo
|
||||
- **[Router](/blocks/router)** - Use AI to intelligently route requests to different paths
|
||||
- **[Evaluator](/blocks/evaluator)** - Score and assess content quality using AI
|
||||
|
||||
### Control Flow Blocks
|
||||
- **[Variables](/blocks/variables)** - Set and manage workflow-scoped variables
|
||||
- **[Wait](/blocks/wait)** - Pause workflow execution for a specified time delay
|
||||
|
||||
### Output Blocks
|
||||
- **[Response](/blocks/response)** - Format and return final results from your workflow
|
||||
|
||||
@@ -123,4 +127,10 @@ Each block type has specific configuration options:
|
||||
<Card title="Condition Block" href="/blocks/condition">
|
||||
Create branching logic based on data evaluation
|
||||
</Card>
|
||||
<Card title="Variables Block" href="/blocks/variables">
|
||||
Set and manage workflow-scoped variables
|
||||
</Card>
|
||||
<Card title="Wait Block" href="/blocks/wait">
|
||||
Pause workflow execution for specified time delays
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image'
|
||||
|
||||
The Loop block is a container block in Sim that allows you to create iterative workflows by executing a group of blocks repeatedly. Loops enable iterative processing in your workflows.
|
||||
|
||||
The Loop block supports two types of iteration:
|
||||
The Loop block supports four types of iteration:
|
||||
|
||||
<Callout type="info">
|
||||
Loop blocks are container nodes that can hold other blocks inside them. The blocks inside a loop will execute multiple times based on your configuration.
|
||||
@@ -27,7 +27,7 @@ The Loop block enables you to:
|
||||
<strong>Repeat operations</strong>: Execute blocks a fixed number of times
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Sequential processing</strong>: Handle data transformation in ordered iterations
|
||||
<strong>Loop on conditions</strong>: Continue executing while or until a condition is met
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Aggregate results</strong>: Collect outputs from all loop iterations
|
||||
@@ -47,9 +47,9 @@ The Loop block executes contained blocks through sequential iteration:
|
||||
|
||||
### Loop Type
|
||||
|
||||
Choose between two types of loops:
|
||||
Choose between four types of loops:
|
||||
|
||||
<Tabs items={['For Loop', 'ForEach Loop']}>
|
||||
<Tabs items={['For Loop', 'ForEach Loop', 'While Loop', 'Do-While Loop']}>
|
||||
<Tab>
|
||||
**For Loop (Iterations)** - A numeric loop that executes a fixed number of times:
|
||||
|
||||
@@ -96,6 +96,54 @@ Choose between two types of loops:
|
||||
- Iteration 3: Process "orange"
|
||||
```
|
||||
</Tab>
|
||||
<Tab>
|
||||
**While Loop (Condition-based)** - Continues executing while a condition evaluates to true:
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/loop-3.png"
|
||||
alt="While Loop with condition"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Use this when you need to loop until a specific condition is met. The condition is checked **before** each iteration.
|
||||
|
||||
```
|
||||
Example: While <variable.i> < 10
|
||||
- Check condition → Execute if true
|
||||
- Inside loop: Increment <variable.i>
|
||||
- Inside loop: Variables assigns i = <variable.i> + 1
|
||||
- Check condition → Execute if true
|
||||
- Check condition → Exit if false
|
||||
```
|
||||
</Tab>
|
||||
<Tab>
|
||||
**Do-While Loop (Condition-based)** - Executes at least once, then continues while a condition is true:
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/loop-3.png"
|
||||
alt="Do-While Loop with condition"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Use this when you need to execute at least once, then loop until a condition is met. The condition is checked **after** each iteration.
|
||||
|
||||
```
|
||||
Example: Do-while <variable.i> < 10
|
||||
- Execute blocks
|
||||
- Inside loop: Increment <variable.i>
|
||||
- Inside loop: Variables assigns i = <variable.i> + 1
|
||||
- Check condition → Continue if true
|
||||
- Check condition → Exit if false
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## How to Use Loops
|
||||
@@ -139,6 +187,19 @@ After a loop completes, you can access aggregated results:
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
### Counter with While Loop
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Process items with counter-based loop</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>Initialize workflow variable: `i = 0`</li>
|
||||
<li>While loop with condition: `<variable.i>` \< 10</li>
|
||||
<li>Inside loop: Agent processes item at index `<variable.i>`</li>
|
||||
<li>Inside loop: Variables increments `i = <variable.i> + 1`</li>
|
||||
<li>Loop continues while i is less than 10</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Limitations
|
||||
@@ -162,7 +223,7 @@ After a loop completes, you can access aggregated results:
|
||||
<Tab>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Loop Type</strong>: Choose between 'for' or 'forEach'
|
||||
<strong>Loop Type</strong>: Choose between 'for', 'forEach', 'while', or 'doWhile'
|
||||
</li>
|
||||
<li>
|
||||
<strong>Iterations</strong>: Number of times to execute (for loops)
|
||||
@@ -170,6 +231,9 @@ After a loop completes, you can access aggregated results:
|
||||
<li>
|
||||
<strong>Collection</strong>: Array or object to iterate over (forEach loops)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Condition</strong>: Boolean expression to evaluate (while/do-while loops)
|
||||
</li>
|
||||
</ul>
|
||||
</Tab>
|
||||
<Tab>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"parallel",
|
||||
"response",
|
||||
"router",
|
||||
"variables",
|
||||
"wait",
|
||||
"workflow"
|
||||
]
|
||||
}
|
||||
|
||||
123
apps/docs/content/docs/en/blocks/variables.mdx
Normal file
123
apps/docs/content/docs/en/blocks/variables.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Variables
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
The Variables block updates workflow variables during execution. Variables must first be initialized in your workflow's Variables section, then you can use this block to update their values as your workflow runs.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/variables.png"
|
||||
alt="Variables Block"
|
||||
width={500}
|
||||
height={350}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout>
|
||||
Access variables anywhere in your workflow using `<variable.variableName>` syntax.
|
||||
</Callout>
|
||||
|
||||
## Overview
|
||||
|
||||
The Variables block enables you to:
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
<strong>Update workflow variables</strong>: Change variable values during execution
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Store dynamic data</strong>: Capture block outputs into variables
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Maintain state</strong>: Track counters, flags, and intermediate results
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## How to Use Variables
|
||||
|
||||
### 1. Initialize in Workflow Variables
|
||||
|
||||
First, create your variables in the workflow's Variables section (accessible from the workflow settings):
|
||||
|
||||
```
|
||||
customerEmail = ""
|
||||
retryCount = 0
|
||||
currentStatus = "pending"
|
||||
```
|
||||
|
||||
### 2. Update with Variables Block
|
||||
|
||||
Use the Variables block to update these values during execution:
|
||||
|
||||
```
|
||||
customerEmail = <api.email>
|
||||
retryCount = <variable.retryCount> + 1
|
||||
currentStatus = "processing"
|
||||
```
|
||||
|
||||
### 3. Access Anywhere
|
||||
|
||||
Reference variables in any block:
|
||||
|
||||
```
|
||||
Agent prompt: "Send email to <variable.customerEmail>"
|
||||
Condition: <variable.retryCount> < 5
|
||||
API body: {"status": "<variable.currentStatus>"}
|
||||
```
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Loop Counter and State
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Track progress through loop iterations</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>Initialize in workflow: `itemsProcessed = 0`, `lastResult = ""`</li>
|
||||
<li>Loop iterates over items</li>
|
||||
<li>Inside loop: Agent processes current item</li>
|
||||
<li>Inside loop: Variables updates `itemsProcessed = <variable.itemsProcessed> + 1`</li>
|
||||
<li>Inside loop: Variables updates `lastResult = <agent.content>`</li>
|
||||
<li>Next iteration: Access `<variable.lastResult>` to compare with current result</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
### Retry Logic
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Track API retry attempts</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>Initialize in workflow: `retryCount = 0`</li>
|
||||
<li>API block attempts request</li>
|
||||
<li>If failed, Variables increments: `retryCount = <variable.retryCount> + 1`</li>
|
||||
<li>Condition checks if `<variable.retryCount>` \< 3 to retry or fail</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
### Dynamic Configuration
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Store user context for workflow</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>Initialize in workflow: `userId = ""`, `userTier = ""`</li>
|
||||
<li>API fetches user profile</li>
|
||||
<li>Variables stores: `userId = <api.id>`, `userTier = <api.tier>`</li>
|
||||
<li>Agent personalizes response using `<variable.userTier>`</li>
|
||||
<li>API uses `<variable.userId>` for logging</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<variables.assignments>`**: JSON object with all variable assignments from this block
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Initialize in workflow settings**: Always create variables in the workflow Variables section before using them
|
||||
- **Update dynamically**: Use Variables blocks to update values based on block outputs or calculations
|
||||
- **Use in loops**: Perfect for tracking state across iterations
|
||||
- **Name descriptively**: Use clear names like `currentIndex`, `totalProcessed`, or `lastError`
|
||||
99
apps/docs/content/docs/en/blocks/wait.mdx
Normal file
99
apps/docs/content/docs/en/blocks/wait.mdx
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Wait
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
The Wait block pauses your workflow for a specified amount of time before continuing to the next block. Use it to add delays between actions, respect API rate limits, or space out operations.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/wait.png"
|
||||
alt="Wait Block"
|
||||
width={500}
|
||||
height={350}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
|
||||
The Wait block enables you to:
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
<strong>Add time delays</strong>: Pause execution between workflow steps
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Respect rate limits</strong>: Space out API calls to stay within limits
|
||||
</Step>
|
||||
<Step>
|
||||
<strong>Schedule sequences</strong>: Create timed workflows with delays between actions
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Configuration
|
||||
|
||||
### Wait Amount
|
||||
|
||||
Enter the duration to pause execution:
|
||||
- **Input**: Positive number
|
||||
- **Maximum**: 600 seconds (10 minutes) or 10 minutes
|
||||
|
||||
### Unit
|
||||
|
||||
Choose the time unit:
|
||||
- **Seconds**: For short, precise delays
|
||||
- **Minutes**: For longer pauses
|
||||
|
||||
<Callout type="info">
|
||||
Wait blocks can be cancelled by stopping the workflow. The maximum wait time is 10 minutes.
|
||||
</Callout>
|
||||
|
||||
## Outputs
|
||||
|
||||
- **`<wait.waitDuration>`**: The wait duration in milliseconds
|
||||
- **`<wait.status>`**: Status of the wait ('waiting', 'completed', or 'cancelled')
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### API Rate Limiting
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Stay within API rate limits</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>API block makes first request</li>
|
||||
<li>Wait block pauses for 2 seconds</li>
|
||||
<li>API block makes second request</li>
|
||||
<li>Process continues without hitting rate limits</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
### Timed Notifications
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Send follow-up messages</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>Function sends initial email</li>
|
||||
<li>Wait block pauses for 5 minutes</li>
|
||||
<li>Function sends follow-up email</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
### Processing Delays
|
||||
|
||||
<div className="mb-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">Scenario: Wait for external system</h4>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
<li>API block triggers job in external system</li>
|
||||
<li>Wait block pauses for 30 seconds</li>
|
||||
<li>API block checks job completion status</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Keep waits reasonable**: Use Wait for delays up to 10 minutes. For longer delays, consider scheduled workflows
|
||||
- **Monitor execution time**: Remember that waits extend total workflow duration
|
||||
BIN
apps/docs/public/static/blocks/loop-3.png
Normal file
BIN
apps/docs/public/static/blocks/loop-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/docs/public/static/blocks/loop-4.png
Normal file
BIN
apps/docs/public/static/blocks/loop-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
apps/docs/public/static/blocks/variables.png
Normal file
BIN
apps/docs/public/static/blocks/variables.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
apps/docs/public/static/blocks/wait.png
Normal file
BIN
apps/docs/public/static/blocks/wait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -25,7 +25,8 @@ const BlockDataSchema = z.object({
|
||||
height: z.number().optional(),
|
||||
collection: z.unknown().optional(),
|
||||
count: z.number().optional(),
|
||||
loopType: z.enum(['for', 'forEach']).optional(),
|
||||
loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).optional(),
|
||||
whileCondition: z.string().optional(),
|
||||
parallelType: z.enum(['collection', 'count']).optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
@@ -78,8 +79,9 @@ const LoopSchema = z.object({
|
||||
id: z.string(),
|
||||
nodes: z.array(z.string()),
|
||||
iterations: z.number(),
|
||||
loopType: z.enum(['for', 'forEach']),
|
||||
loopType: z.enum(['for', 'forEach', 'while', 'doWhile']),
|
||||
forEachItems: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(),
|
||||
whileCondition: z.string().optional(),
|
||||
})
|
||||
|
||||
const ParallelSchema = z.object({
|
||||
|
||||
@@ -266,7 +266,9 @@ function PinnedLogs({
|
||||
<ChevronLeft className='h-4 w-4' />
|
||||
</button>
|
||||
<span className='px-2 text-muted-foreground text-xs'>
|
||||
{currentIterationIndex + 1} / {iterationInfo.totalIterations}
|
||||
{iterationInfo.totalIterations !== undefined
|
||||
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
|
||||
: `${currentIterationIndex + 1}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextIteration}
|
||||
|
||||
@@ -423,10 +423,12 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) {
|
||||
</span>
|
||||
</div>
|
||||
{/* Iteration tag - only show if iteration context exists */}
|
||||
{entry.iterationCurrent !== undefined && entry.iterationTotal !== undefined && (
|
||||
{entry.iterationCurrent !== undefined && (
|
||||
<div className='flex h-5 items-center rounded-lg bg-secondary px-2'>
|
||||
<span className='font-normal text-muted-foreground text-xs leading-normal'>
|
||||
{entry.iterationCurrent}/{entry.iterationTotal}
|
||||
{entry.iterationTotal !== undefined
|
||||
? `${entry.iterationCurrent}/${entry.iterationTotal}`
|
||||
: `${entry.iterationCurrent}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1396,6 +1396,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const caret = e.target.selectionStart ?? newValue.length
|
||||
const active = getActiveMentionQueryAtPosition(caret, newValue)
|
||||
if (active) {
|
||||
// Sync workflow blocks when user types @
|
||||
ensureWorkflowBlocksLoaded()
|
||||
setShowMentionMenu(true)
|
||||
setInAggregated(false)
|
||||
if (openSubmenuFor) {
|
||||
@@ -1827,6 +1829,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const pos = textarea.selectionStart ?? message.length
|
||||
const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1))
|
||||
insertAtCursor(needsSpaceBefore ? ' @' : '@')
|
||||
// Sync workflow blocks when user clicks @ button
|
||||
ensureWorkflowBlocksLoaded()
|
||||
// Open the menu at top level
|
||||
setShowMentionMenu(true)
|
||||
setOpenSubmenuFor(null)
|
||||
@@ -1904,58 +1908,33 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
>([])
|
||||
const [isLoadingWorkflowBlocks, setIsLoadingWorkflowBlocks] = useState(false)
|
||||
|
||||
// Sync workflow blocks from store whenever they change
|
||||
useEffect(() => {
|
||||
const syncWorkflowBlocks = async () => {
|
||||
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
|
||||
setWorkflowBlocks([])
|
||||
logger.debug('No workflow blocks to sync', {
|
||||
workflowId,
|
||||
hasBlocks: !!workflowStoreBlocks,
|
||||
blockCount: Object.keys(workflowStoreBlocks || {}).length,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Map to display with block registry icons/colors
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
|
||||
const reg = (blockRegistry as any)[b.type]
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name || b.id,
|
||||
type: b.type,
|
||||
iconComponent: reg?.icon,
|
||||
bgColor: reg?.bgColor || '#6B7280',
|
||||
}
|
||||
})
|
||||
setWorkflowBlocks(mapped)
|
||||
logger.debug('Synced workflow blocks for mention menu', {
|
||||
count: mapped.length,
|
||||
blocks: mapped.map((b) => b.name),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.debug('Failed to sync workflow blocks:', error)
|
||||
}
|
||||
// Sync workflow blocks only when user opens the mention menu
|
||||
const ensureWorkflowBlocksLoaded = async () => {
|
||||
if (!workflowId || !workflowStoreBlocks || Object.keys(workflowStoreBlocks).length === 0) {
|
||||
setWorkflowBlocks([])
|
||||
return
|
||||
}
|
||||
|
||||
syncWorkflowBlocks()
|
||||
}, [workflowStoreBlocks, workflowId])
|
||||
|
||||
const ensureWorkflowBlocksLoaded = async () => {
|
||||
// Since blocks are now synced from store via useEffect, this can be a no-op
|
||||
// or just ensure the blocks are loaded in the store
|
||||
if (!workflowId) return
|
||||
|
||||
// Debug: Log current state
|
||||
logger.debug('ensureWorkflowBlocksLoaded called', {
|
||||
workflowId,
|
||||
storeBlocksCount: Object.keys(workflowStoreBlocks || {}).length,
|
||||
workflowBlocksCount: workflowBlocks.length,
|
||||
})
|
||||
|
||||
// Blocks will be automatically synced from the store
|
||||
try {
|
||||
setIsLoadingWorkflowBlocks(true)
|
||||
// Map to display with block registry icons/colors
|
||||
const { registry: blockRegistry } = await import('@/blocks/registry')
|
||||
const mapped = Object.values(workflowStoreBlocks).map((b: any) => {
|
||||
const reg = (blockRegistry as any)[b.type]
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name || b.id,
|
||||
type: b.type,
|
||||
iconComponent: reg?.icon,
|
||||
bgColor: reg?.bgColor || '#6B7280',
|
||||
}
|
||||
})
|
||||
setWorkflowBlocks(mapped)
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync workflow blocks:', error)
|
||||
} finally {
|
||||
setIsLoadingWorkflowBlocks(false)
|
||||
}
|
||||
}
|
||||
|
||||
const insertWorkflowBlockMention = (blk: { id: string; name: string }) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
type LoopType = 'for' | 'forEach'
|
||||
type LoopType = 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
type ParallelType = 'count' | 'collection'
|
||||
|
||||
interface IterationNodeData {
|
||||
@@ -46,14 +46,20 @@ interface IterationBadgesProps {
|
||||
|
||||
const CONFIG = {
|
||||
loop: {
|
||||
typeLabels: { for: 'For Loop', forEach: 'For Each' },
|
||||
typeLabels: {
|
||||
for: 'For Loop',
|
||||
forEach: 'For Each',
|
||||
while: 'While Loop',
|
||||
doWhile: 'Do While Loop',
|
||||
},
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
},
|
||||
condition: 'whileCondition' as const,
|
||||
} as any,
|
||||
},
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
@@ -78,17 +84,30 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
// Determine current type and values
|
||||
const currentType = (data?.[config.typeKey] ||
|
||||
(iterationType === 'loop' ? 'for' : 'count')) as any
|
||||
|
||||
// Determine if we're in count mode, collection mode, or condition mode
|
||||
const isCountMode =
|
||||
(iterationType === 'loop' && currentType === 'for') ||
|
||||
(iterationType === 'parallel' && currentType === 'count')
|
||||
const isConditionMode =
|
||||
iterationType === 'loop' && (currentType === 'while' || currentType === 'doWhile')
|
||||
|
||||
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
|
||||
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
|
||||
const configCondition =
|
||||
iterationType === 'loop'
|
||||
? ((nodeConfig as any)?.whileCondition ?? (data as any)?.whileCondition ?? '')
|
||||
: ''
|
||||
|
||||
const iterations = configIterations
|
||||
const collectionString =
|
||||
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
|
||||
const conditionString = typeof configCondition === 'string' ? configCondition : ''
|
||||
|
||||
// State management
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const inputValue = tempInputValue ?? iterations.toString()
|
||||
const editorValue = collectionString
|
||||
const editorValue = isConditionMode ? conditionString : collectionString
|
||||
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
|
||||
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false)
|
||||
@@ -190,11 +209,6 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
|
||||
)
|
||||
|
||||
// Determine if we're in count mode or collection mode
|
||||
const isCountMode =
|
||||
(iterationType === 'loop' && currentType === 'for') ||
|
||||
(iterationType === 'parallel' && currentType === 'count')
|
||||
|
||||
// Get type options
|
||||
const typeOptions = Object.entries(config.typeLabels)
|
||||
|
||||
@@ -259,7 +273,7 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{isCountMode ? `Iterations: ${iterations}` : 'Items'}
|
||||
{isCountMode ? `Iterations: ${iterations}` : isConditionMode ? 'Condition' : 'Items'}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
@@ -273,7 +287,9 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{isCountMode
|
||||
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
|
||||
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
|
||||
: isConditionMode
|
||||
? 'While Condition'
|
||||
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
|
||||
</div>
|
||||
|
||||
{isCountMode ? (
|
||||
@@ -289,6 +305,44 @@ export function IterationBadges({ nodeId, data, iterationType }: IterationBadges
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : isConditionMode ? (
|
||||
// Code editor for while condition
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
|
||||
{conditionString === '' && (
|
||||
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
|
||||
{'<counter.value> < 10'}
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
value={conditionString}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className='w-full focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-2 text-[10px] text-muted-foreground'>
|
||||
JavaScript expression that evaluates to true/false. Type "{'<'}" to reference
|
||||
blocks.
|
||||
</div>
|
||||
{showTagDropdown && (
|
||||
<TagDropdown
|
||||
visible={showTagDropdown}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={nodeId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={conditionString}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTagDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Code editor for collection-based mode
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
|
||||
@@ -28,4 +28,5 @@ export { Table } from './table'
|
||||
export { TimeInput } from './time-input'
|
||||
export { ToolInput } from './tool-input/tool-input'
|
||||
export { TriggerConfig } from './trigger-config/trigger-config'
|
||||
export { VariablesInput } from './variables-input/variables-input'
|
||||
export { WebhookConfig } from './webhook/webhook'
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Plus, Trash } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
interface VariableAssignment {
|
||||
id: string
|
||||
variableId?: string
|
||||
variableName: string
|
||||
type: 'string' | 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'json'
|
||||
value: string
|
||||
isExisting: boolean
|
||||
}
|
||||
|
||||
interface VariablesInputProps {
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
isPreview?: boolean
|
||||
previewValue?: VariableAssignment[] | null
|
||||
disabled?: boolean
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_ASSIGNMENT: Omit<VariableAssignment, 'id'> = {
|
||||
variableName: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
isExisting: false,
|
||||
}
|
||||
|
||||
export function VariablesInput({
|
||||
blockId,
|
||||
subBlockId,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
disabled = false,
|
||||
isConnecting = false,
|
||||
}: VariablesInputProps) {
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<VariableAssignment[]>(blockId, subBlockId)
|
||||
const { variables: workflowVariables } = useVariablesStore()
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const [activeFieldId, setActiveFieldId] = useState<string | null>(null)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
const valueInputRefs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement>>({})
|
||||
const overlayRefs = useRef<Record<string, HTMLDivElement>>({})
|
||||
const [dragHighlight, setDragHighlight] = useState<Record<string, boolean>>({})
|
||||
|
||||
const currentWorkflowVariables = Object.values(workflowVariables).filter(
|
||||
(v: Variable) => v.workflowId === workflowId
|
||||
)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const assignments: VariableAssignment[] = value || []
|
||||
|
||||
const getAvailableVariablesFor = (currentAssignmentId: string) => {
|
||||
const otherSelectedIds = new Set(
|
||||
assignments
|
||||
.filter((a) => a.id !== currentAssignmentId)
|
||||
.map((a) => a.variableId)
|
||||
.filter((id): id is string => !!id)
|
||||
)
|
||||
|
||||
return currentWorkflowVariables.filter((variable) => !otherSelectedIds.has(variable.id))
|
||||
}
|
||||
|
||||
const hasNoWorkflowVariables = currentWorkflowVariables.length === 0
|
||||
const allVariablesAssigned =
|
||||
!hasNoWorkflowVariables && getAvailableVariablesFor('new').length === 0
|
||||
|
||||
const addAssignment = () => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
const newAssignment: VariableAssignment = {
|
||||
...DEFAULT_ASSIGNMENT,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
setStoreValue([...(assignments || []), newAssignment])
|
||||
}
|
||||
|
||||
const removeAssignment = (id: string) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue((assignments || []).filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
const updateAssignment = (id: string, updates: Partial<VariableAssignment>) => {
|
||||
if (isPreview || disabled) return
|
||||
setStoreValue((assignments || []).map((a) => (a.id === id ? { ...a, ...updates } : a)))
|
||||
}
|
||||
|
||||
const handleVariableSelect = (assignmentId: string, variableId: string) => {
|
||||
const selectedVariable = currentWorkflowVariables.find((v) => v.id === variableId)
|
||||
if (selectedVariable) {
|
||||
updateAssignment(assignmentId, {
|
||||
variableId: selectedVariable.id,
|
||||
variableName: selectedVariable.name,
|
||||
type: selectedVariable.type as any,
|
||||
isExisting: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagSelect = (tag: string) => {
|
||||
if (!activeFieldId) return
|
||||
|
||||
const assignment = assignments.find((a) => a.id === activeFieldId)
|
||||
if (!assignment) return
|
||||
|
||||
const currentValue = assignment.value || ''
|
||||
|
||||
const textBeforeCursor = currentValue.slice(0, cursorPosition)
|
||||
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
|
||||
|
||||
const newValue =
|
||||
currentValue.slice(0, lastOpenBracket) + tag + currentValue.slice(cursorPosition)
|
||||
|
||||
updateAssignment(activeFieldId, { value: newValue })
|
||||
setShowTags(false)
|
||||
|
||||
setTimeout(() => {
|
||||
const inputEl = valueInputRefs.current[activeFieldId]
|
||||
if (inputEl) {
|
||||
inputEl.focus()
|
||||
const newCursorPos = lastOpenBracket + tag.length
|
||||
inputEl.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
const handleValueInputChange = (
|
||||
assignmentId: string,
|
||||
newValue: string,
|
||||
selectionStart?: number
|
||||
) => {
|
||||
updateAssignment(assignmentId, { value: newValue })
|
||||
|
||||
if (selectionStart !== undefined) {
|
||||
setCursorPosition(selectionStart)
|
||||
setActiveFieldId(assignmentId)
|
||||
|
||||
const shouldShowTags = checkTagTrigger(newValue, selectionStart)
|
||||
setShowTags(shouldShowTags.show)
|
||||
|
||||
if (shouldShowTags.show) {
|
||||
const textBeforeCursor = newValue.slice(0, selectionStart)
|
||||
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
|
||||
const tagContent = textBeforeCursor.slice(lastOpenBracket + 1)
|
||||
const dotIndex = tagContent.indexOf('.')
|
||||
const sourceBlock = dotIndex > 0 ? tagContent.slice(0, dotIndex) : null
|
||||
setActiveSourceBlockId(sourceBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, assignmentId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
|
||||
|
||||
const tag = e.dataTransfer.getData('text/plain')
|
||||
if (tag?.startsWith('<')) {
|
||||
const assignment = assignments.find((a) => a.id === assignmentId)
|
||||
if (!assignment) return
|
||||
|
||||
const currentValue = assignment.value || ''
|
||||
updateAssignment(assignmentId, { value: currentValue + tag })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, assignmentId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [assignmentId]: true }))
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent, assignmentId: string) => {
|
||||
e.preventDefault()
|
||||
setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
|
||||
}
|
||||
|
||||
if (isPreview && (!assignments || assignments.length === 0)) {
|
||||
return (
|
||||
<div className='flex items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 p-4 text-center text-muted-foreground text-sm'>
|
||||
No variable assignments defined
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
{assignments.map((assignment) => {
|
||||
return (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className='group relative rounded-lg border border-border/60 bg-background p-3 transition-all hover:border-border'
|
||||
>
|
||||
{!isPreview && !disabled && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100'
|
||||
onClick={() => removeAssignment(assignment.id)}
|
||||
>
|
||||
<Trash className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-muted-foreground text-xs'>Variable</Label>
|
||||
<Select
|
||||
value={assignment.variableId || assignment.variableName || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
return
|
||||
}
|
||||
handleVariableSelect(assignment.id, value)
|
||||
}}
|
||||
disabled={isPreview || disabled}
|
||||
>
|
||||
<SelectTrigger className='h-9 bg-white dark:bg-background'>
|
||||
<SelectValue placeholder='Select a variable...' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(() => {
|
||||
const availableVars = getAvailableVariablesFor(assignment.id)
|
||||
return availableVars.length > 0 ? (
|
||||
availableVars.map((variable) => (
|
||||
<SelectItem key={variable.id} value={variable.id}>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{variable.name}</span>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{variable.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className='p-2 text-center text-muted-foreground text-sm'>
|
||||
{currentWorkflowVariables.length > 0
|
||||
? 'All variables have been assigned.'
|
||||
: 'No variables defined in this workflow.'}
|
||||
{currentWorkflowVariables.length === 0 && (
|
||||
<>
|
||||
<br />
|
||||
Add them in the Variables panel.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-muted-foreground text-xs'>Type</Label>
|
||||
<Input
|
||||
value={assignment.type || 'string'}
|
||||
disabled={true}
|
||||
className='h-9 bg-muted/50 text-muted-foreground'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='relative space-y-1.5'>
|
||||
<Label className='text-muted-foreground text-xs'>Value</Label>
|
||||
{assignment.type === 'object' || assignment.type === 'array' ? (
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
assignment.type === 'object'
|
||||
? '{\n "key": "value"\n}'
|
||||
: '[\n 1, 2, 3\n]'
|
||||
}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'min-h-[120px] border border-input bg-white font-mono text-sm dark:bg-background',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
if (el) valueInputRefs.current[assignment.id] = el
|
||||
}}
|
||||
value={assignment.value || ''}
|
||||
onChange={(e) =>
|
||||
handleValueInputChange(
|
||||
assignment.id,
|
||||
e.target.value,
|
||||
e.target.selectionStart ?? undefined
|
||||
)
|
||||
}
|
||||
placeholder={`${assignment.type} value`}
|
||||
disabled={isPreview || disabled}
|
||||
className={cn(
|
||||
'h-9 bg-white text-transparent caret-foreground dark:bg-background',
|
||||
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
|
||||
)}
|
||||
onDrop={(e) => handleDrop(e, assignment.id)}
|
||||
onDragOver={(e) => handleDragOver(e, assignment.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, assignment.id)}
|
||||
/>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) overlayRefs.current[assignment.id] = el
|
||||
}}
|
||||
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'
|
||||
>
|
||||
<div className='w-full whitespace-nowrap'>
|
||||
{formatDisplayText(assignment.value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTags && activeFieldId === assignment.id && (
|
||||
<TagDropdown
|
||||
visible={showTags}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={blockId}
|
||||
activeSourceBlockId={activeSourceBlockId}
|
||||
inputValue={assignment.value || ''}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTags(false)}
|
||||
className='absolute top-full left-0 z-50 mt-1'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPreview && !disabled && (
|
||||
<Button
|
||||
onClick={addAssignment}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='w-full border-dashed'
|
||||
disabled={hasNoWorkflowVariables || allVariablesAssigned}
|
||||
>
|
||||
{!hasNoWorkflowVariables && <Plus className='mr-2 h-4 w-4' />}
|
||||
{hasNoWorkflowVariables
|
||||
? 'No variables found'
|
||||
: allVariablesAssigned
|
||||
? 'All Variables Assigned'
|
||||
: 'Add Variable Assignment'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
TimeInput,
|
||||
ToolInput,
|
||||
TriggerConfig,
|
||||
VariablesInput,
|
||||
WebhookConfig,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
@@ -473,6 +474,18 @@ export const SubBlock = memo(
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'variables-input': {
|
||||
return (
|
||||
<VariablesInput
|
||||
blockId={blockId}
|
||||
subBlockId={config.id}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
disabled={isDisabled}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'response-format':
|
||||
return (
|
||||
<ResponseFormat
|
||||
|
||||
47
apps/sim/blocks/blocks/variables.ts
Normal file
47
apps/sim/blocks/blocks/variables.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Variable } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
export const VariablesBlock: BlockConfig = {
|
||||
type: 'variables',
|
||||
name: 'Variables',
|
||||
description: 'Set workflow-scoped variables',
|
||||
longDescription:
|
||||
'Set workflow-scoped variables that can be accessed throughout the workflow using <variable.variableName> syntax. All Variables blocks share the same namespace, so later blocks can update previously set variables.',
|
||||
bgColor: '#8B5CF6',
|
||||
bestPractices: `
|
||||
- Variables are workflow-scoped and persist throughout execution (but not between executions)
|
||||
- Reference variables using <variable.variableName> syntax in any block
|
||||
- Variable names should be descriptive and follow camelCase or snake_case convention
|
||||
- Any Variables block can update existing variables by setting the same variable name
|
||||
- Variables do not appear as block outputs - they're accessed via the <variable.> prefix
|
||||
`,
|
||||
icon: Variable,
|
||||
category: 'blocks',
|
||||
docsLink: 'https://docs.sim.ai/blocks/variables',
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'variables',
|
||||
title: 'Variable Assignments',
|
||||
type: 'variables-input',
|
||||
layout: 'full',
|
||||
description:
|
||||
'Select workflow variables and update their values during execution. Access them anywhere using <variable.variableName> syntax.',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [],
|
||||
},
|
||||
inputs: {
|
||||
variables: {
|
||||
type: 'json',
|
||||
description: 'Array of variable objects with name and value properties',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
assignments: {
|
||||
type: 'json',
|
||||
description: 'JSON object mapping variable names to their assigned values',
|
||||
},
|
||||
},
|
||||
}
|
||||
71
apps/sim/blocks/blocks/wait.ts
Normal file
71
apps/sim/blocks/blocks/wait.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { PauseCircle } from 'lucide-react'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
const WaitIcon = (props: SVGProps<SVGSVGElement>) => createElement(PauseCircle, props)
|
||||
|
||||
export const WaitBlock: BlockConfig = {
|
||||
type: 'wait',
|
||||
name: 'Wait',
|
||||
description: 'Pause workflow execution for a specified time delay',
|
||||
longDescription:
|
||||
'Pauses workflow execution for a specified time interval. The wait executes a simple sleep for the configured duration.',
|
||||
bestPractices: `
|
||||
- Use for simple time delays (max 10 minutes)
|
||||
- Configure the wait amount and unit (seconds or minutes)
|
||||
- Time-based waits are interruptible via workflow cancellation
|
||||
- Enter a positive number for the wait amount
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#F59E0B',
|
||||
icon: WaitIcon,
|
||||
docsLink: 'https://docs.sim.ai/blocks/wait',
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'timeValue',
|
||||
title: 'Wait Amount',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
description: 'How long to wait. Max: 600 seconds or 10 minutes',
|
||||
placeholder: '10',
|
||||
value: () => '10',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'timeUnit',
|
||||
title: 'Unit',
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: [
|
||||
{ label: 'Seconds', id: 'seconds' },
|
||||
{ label: 'Minutes', id: 'minutes' },
|
||||
],
|
||||
value: () => 'seconds',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [],
|
||||
},
|
||||
inputs: {
|
||||
timeValue: {
|
||||
type: 'string',
|
||||
description: 'Wait duration value',
|
||||
},
|
||||
timeUnit: {
|
||||
type: 'string',
|
||||
description: 'Wait duration unit (seconds or minutes)',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
waitDuration: {
|
||||
type: 'number',
|
||||
description: 'Wait duration in milliseconds',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Status of the wait block (waiting, completed, cancelled)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -73,7 +73,9 @@ import { ThinkingBlock } from '@/blocks/blocks/thinking'
|
||||
import { TranslateBlock } from '@/blocks/blocks/translate'
|
||||
import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
|
||||
import { TypeformBlock } from '@/blocks/blocks/typeform'
|
||||
import { VariablesBlock } from '@/blocks/blocks/variables'
|
||||
import { VisionBlock } from '@/blocks/blocks/vision'
|
||||
import { WaitBlock } from '@/blocks/blocks/wait'
|
||||
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
|
||||
import { WebflowBlock } from '@/blocks/blocks/webflow'
|
||||
import { WebhookBlock } from '@/blocks/blocks/webhook'
|
||||
@@ -165,7 +167,9 @@ export const registry: Record<string, BlockConfig> = {
|
||||
translate: TranslateBlock,
|
||||
twilio_sms: TwilioSMSBlock,
|
||||
typeform: TypeformBlock,
|
||||
variables: VariablesBlock,
|
||||
vision: VisionBlock,
|
||||
wait: WaitBlock,
|
||||
wealthbox: WealthboxBlock,
|
||||
webflow: WebflowBlock,
|
||||
webhook: WebhookBlock,
|
||||
|
||||
@@ -70,6 +70,7 @@ export type SubBlockType =
|
||||
| 'response-format' // Response structure format
|
||||
| 'file-upload' // File uploader
|
||||
| 'input-mapping' // Map parent variables to child workflow input schema
|
||||
| 'variables-input' // Variable assignments for updating workflow variables
|
||||
|
||||
export type SubBlockLayout = 'full' | 'half'
|
||||
|
||||
|
||||
@@ -3795,3 +3795,24 @@ export function WebflowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Variable(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M7 8l-4 4 4 4' />
|
||||
<path d='M17 8l4 4-4 4' />
|
||||
<line x1='14' y1='4' x2='10' y2='20' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -585,13 +585,43 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
)
|
||||
|
||||
let loopBlockGroup: BlockTagGroup | null = null
|
||||
|
||||
// Check if blockId IS a loop block (for editing loop config like while condition)
|
||||
const isLoopBlock = blocks[blockId]?.type === 'loop'
|
||||
const currentLoop = isLoopBlock ? loops[blockId] : null
|
||||
|
||||
// Check if blockId is INSIDE a loop
|
||||
const containingLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(blockId))
|
||||
|
||||
let containingLoopBlockId: string | null = null
|
||||
if (containingLoop) {
|
||||
|
||||
// Prioritize current loop if editing the loop block itself
|
||||
if (currentLoop && isLoopBlock) {
|
||||
containingLoopBlockId = blockId
|
||||
const loopType = currentLoop.loopType || 'for'
|
||||
const contextualTags: string[] = ['index', 'currentIteration']
|
||||
if (loopType === 'forEach') {
|
||||
contextualTags.push('currentItem')
|
||||
contextualTags.push('items')
|
||||
}
|
||||
|
||||
const loopBlock = blocks[blockId]
|
||||
if (loopBlock) {
|
||||
const loopBlockName = loopBlock.name || loopBlock.type
|
||||
|
||||
loopBlockGroup = {
|
||||
blockName: loopBlockName,
|
||||
blockId: blockId,
|
||||
blockType: 'loop',
|
||||
tags: contextualTags,
|
||||
distance: 0,
|
||||
}
|
||||
}
|
||||
} else if (containingLoop) {
|
||||
const [loopId, loop] = containingLoop
|
||||
containingLoopBlockId = loopId
|
||||
const loopType = loop.loopType || 'for'
|
||||
const contextualTags: string[] = ['index']
|
||||
const contextualTags: string[] = ['index', 'currentIteration']
|
||||
if (loopType === 'forEach') {
|
||||
contextualTags.push('currentItem')
|
||||
contextualTags.push('items')
|
||||
|
||||
@@ -48,6 +48,8 @@ export const setupHandlerMocks = () => {
|
||||
LoopBlockHandler: createMockHandler('loop'),
|
||||
ParallelBlockHandler: createMockHandler('parallel'),
|
||||
WorkflowBlockHandler: createMockHandler('workflow'),
|
||||
VariablesBlockHandler: createMockHandler('variables'),
|
||||
WaitBlockHandler: createMockHandler('wait'),
|
||||
GenericBlockHandler: createMockHandler('generic'),
|
||||
ResponseBlockHandler: createMockHandler('response'),
|
||||
}))
|
||||
|
||||
@@ -15,6 +15,8 @@ export enum BlockType {
|
||||
WORKFLOW = 'workflow', // Deprecated - kept for backwards compatibility
|
||||
WORKFLOW_INPUT = 'workflow_input', // Current workflow block type
|
||||
STARTER = 'starter',
|
||||
VARIABLES = 'variables',
|
||||
WAIT = 'wait',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -332,7 +332,7 @@ describe('ConditionBlockHandler', () => {
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
/^Evaluation error in condition "if": Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
|
||||
/^Evaluation error in condition "if": Evaluation error in condition: Cannot read properties of undefined \(reading 'doSomething'\)\. \(Resolved: context\.nonExistentProperty\.doSomething\(\)\)$/
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,61 @@ import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('ConditionBlockHandler')
|
||||
|
||||
/**
|
||||
* Evaluates a single condition expression with variable/block reference resolution
|
||||
* Returns true if condition is met, false otherwise
|
||||
*/
|
||||
export async function evaluateConditionExpression(
|
||||
conditionExpression: string,
|
||||
context: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
resolver: InputResolver,
|
||||
providedEvalContext?: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
// Build evaluation context - use provided context or just loop context
|
||||
const evalContext = providedEvalContext || {
|
||||
// Add loop context if applicable
|
||||
...(context.loopItems.get(block.id) || {}),
|
||||
}
|
||||
|
||||
let resolvedConditionValue = conditionExpression
|
||||
try {
|
||||
// Use full resolution pipeline: variables -> block references -> env vars
|
||||
const resolvedVars = resolver.resolveVariableReferences(conditionExpression, block)
|
||||
const resolvedRefs = resolver.resolveBlockReferences(resolvedVars, context, block)
|
||||
resolvedConditionValue = resolver.resolveEnvVariables(resolvedRefs)
|
||||
logger.info(`Resolved condition: from "${conditionExpression}" to "${resolvedConditionValue}"`)
|
||||
} catch (resolveError: any) {
|
||||
logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
|
||||
conditionExpression,
|
||||
resolveError,
|
||||
})
|
||||
throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
|
||||
}
|
||||
|
||||
// Evaluate the RESOLVED condition string
|
||||
try {
|
||||
logger.info(`Evaluating resolved condition: "${resolvedConditionValue}"`, { evalContext })
|
||||
// IMPORTANT: The resolved value (e.g., "some string".length > 0) IS the code to run
|
||||
const conditionMet = new Function(
|
||||
'context',
|
||||
`with(context) { return ${resolvedConditionValue} }`
|
||||
)(evalContext)
|
||||
logger.info(`Condition evaluated to: ${conditionMet}`)
|
||||
return Boolean(conditionMet)
|
||||
} catch (evalError: any) {
|
||||
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
||||
originalCondition: conditionExpression,
|
||||
resolvedCondition: resolvedConditionValue,
|
||||
evalContext,
|
||||
evalError,
|
||||
})
|
||||
throw new Error(
|
||||
`Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Condition blocks that evaluate expressions to determine execution paths.
|
||||
*/
|
||||
@@ -102,35 +157,16 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
continue // Should ideally not happen if 'else' exists and has a connection
|
||||
}
|
||||
|
||||
// 2. Resolve references WITHIN the specific condition's value string
|
||||
// 2. Evaluate the condition using the shared evaluation function
|
||||
const conditionValueString = String(condition.value || '')
|
||||
let resolvedConditionValue = conditionValueString
|
||||
try {
|
||||
// Use full resolution pipeline: variables -> block references -> env vars
|
||||
const resolvedVars = this.resolver.resolveVariableReferences(conditionValueString, block)
|
||||
const resolvedRefs = this.resolver.resolveBlockReferences(resolvedVars, context, block)
|
||||
resolvedConditionValue = this.resolver.resolveEnvVariables(resolvedRefs)
|
||||
logger.info(
|
||||
`Resolved condition "${condition.title}" (${condition.id}): from "${conditionValueString}" to "${resolvedConditionValue}"`
|
||||
const conditionMet = await evaluateConditionExpression(
|
||||
conditionValueString,
|
||||
context,
|
||||
block,
|
||||
this.resolver,
|
||||
evalContext
|
||||
)
|
||||
} catch (resolveError: any) {
|
||||
logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
|
||||
condition,
|
||||
resolveError,
|
||||
})
|
||||
throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
|
||||
}
|
||||
|
||||
// 3. Evaluate the RESOLVED condition string
|
||||
try {
|
||||
logger.info(`Evaluating resolved condition: "${resolvedConditionValue}"`, {
|
||||
evalContext, // Log the context being used for evaluation
|
||||
})
|
||||
// IMPORTANT: The resolved value (e.g., "some string".length > 0) IS the code to run
|
||||
const conditionMet = new Function(
|
||||
'context',
|
||||
`with(context) { return ${resolvedConditionValue} }`
|
||||
)(evalContext)
|
||||
logger.info(`Condition "${condition.title}" (${condition.id}) met: ${conditionMet}`)
|
||||
|
||||
// Find connection for this condition
|
||||
@@ -143,17 +179,9 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
selectedCondition = condition
|
||||
break // Found the first matching condition
|
||||
}
|
||||
} catch (evalError: any) {
|
||||
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
||||
originalCondition: condition.value,
|
||||
resolvedCondition: resolvedConditionValue,
|
||||
evalContext,
|
||||
evalError,
|
||||
})
|
||||
// Construct a more informative error message
|
||||
throw new Error(
|
||||
`Evaluation error in condition "${condition.title}": ${evalError.message}. (Resolved: ${resolvedConditionValue})`
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to evaluate condition "${condition.title}": ${error.message}`)
|
||||
throw new Error(`Evaluation error in condition "${condition.title}": ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ParallelBlockHandler } from '@/executor/handlers/parallel/parallel-hand
|
||||
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
|
||||
import { RouterBlockHandler } from '@/executor/handlers/router/router-handler'
|
||||
import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler'
|
||||
import { VariablesBlockHandler } from '@/executor/handlers/variables/variables-handler'
|
||||
import { WaitBlockHandler } from '@/executor/handlers/wait/wait-handler'
|
||||
import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler'
|
||||
|
||||
export {
|
||||
@@ -23,5 +25,7 @@ export {
|
||||
ResponseBlockHandler,
|
||||
RouterBlockHandler,
|
||||
TriggerBlockHandler,
|
||||
VariablesBlockHandler,
|
||||
WaitBlockHandler,
|
||||
WorkflowBlockHandler,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { evaluateConditionExpression } from '@/executor/handlers/condition/condition-handler'
|
||||
import type { PathTracker } from '@/executor/path/path'
|
||||
import type { InputResolver } from '@/executor/resolver/resolver'
|
||||
import { Routing } from '@/executor/routing/routing'
|
||||
@@ -50,6 +51,8 @@ export class LoopBlockHandler implements BlockHandler {
|
||||
const currentIteration = context.loopIterations.get(block.id) || 1
|
||||
let maxIterations: number
|
||||
let forEachItems: any[] | Record<string, any> | null = null
|
||||
let shouldContinueLoop = true
|
||||
|
||||
if (loop.loopType === 'forEach') {
|
||||
if (
|
||||
!loop.forEachItems ||
|
||||
@@ -82,16 +85,96 @@ export class LoopBlockHandler implements BlockHandler {
|
||||
logger.info(
|
||||
`forEach loop ${block.id} - Items: ${itemsLength}, Max iterations: ${maxIterations}`
|
||||
)
|
||||
} else if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
|
||||
// For while and doWhile loops, set loop context BEFORE evaluating condition
|
||||
// This makes variables like index, currentIteration available in the condition
|
||||
const loopContext = {
|
||||
index: currentIteration - 1, // 0-based index
|
||||
currentIteration, // 1-based iteration number
|
||||
}
|
||||
context.loopItems.set(block.id, loopContext)
|
||||
|
||||
// Evaluate the condition to determine if we should continue
|
||||
if (!loop.whileCondition || loop.whileCondition.trim() === '') {
|
||||
throw new Error(
|
||||
`${loop.loopType} loop "${block.id}" requires a condition expression. Please provide a valid JavaScript expression.`
|
||||
)
|
||||
}
|
||||
|
||||
// For doWhile loops, skip condition evaluation on the first iteration
|
||||
// For while loops, always evaluate the condition
|
||||
if (loop.loopType === 'doWhile' && currentIteration === 1) {
|
||||
shouldContinueLoop = true
|
||||
} else {
|
||||
// Evaluate the condition at the start of each iteration
|
||||
try {
|
||||
if (!this.resolver) {
|
||||
throw new Error('Resolver is required for while/doWhile loop condition evaluation')
|
||||
}
|
||||
shouldContinueLoop = await evaluateConditionExpression(
|
||||
loop.whileCondition,
|
||||
context,
|
||||
block,
|
||||
this.resolver
|
||||
)
|
||||
} catch (error: any) {
|
||||
throw new Error(
|
||||
`Failed to evaluate ${loop.loopType} loop condition for "${block.id}": ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// No max iterations for while/doWhile - rely on condition and workflow timeout
|
||||
maxIterations = Number.MAX_SAFE_INTEGER
|
||||
} else {
|
||||
maxIterations = loop.iterations || DEFAULT_MAX_ITERATIONS
|
||||
logger.info(`For loop ${block.id} - Max iterations: ${maxIterations}`)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}`
|
||||
`Loop ${block.id} - Current iteration: ${currentIteration}, Max iterations: ${maxIterations}, Should continue: ${shouldContinueLoop}`
|
||||
)
|
||||
|
||||
if (currentIteration > maxIterations) {
|
||||
// For while and doWhile loops, check if the condition is false
|
||||
if ((loop.loopType === 'while' || loop.loopType === 'doWhile') && !shouldContinueLoop) {
|
||||
// Mark the loop as completed
|
||||
context.completedLoops.add(block.id)
|
||||
|
||||
// Remove any activated loop-start paths since we're not continuing
|
||||
const loopStartConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'loop-start-source'
|
||||
) || []
|
||||
|
||||
for (const conn of loopStartConnections) {
|
||||
context.activeExecutionPath.delete(conn.target)
|
||||
}
|
||||
|
||||
// Activate the loop-end connections (blocks after the loop)
|
||||
const loopEndConnections =
|
||||
context.workflow?.connections.filter(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === 'loop-end-source'
|
||||
) || []
|
||||
|
||||
for (const conn of loopEndConnections) {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
}
|
||||
|
||||
return {
|
||||
loopId: block.id,
|
||||
currentIteration,
|
||||
maxIterations,
|
||||
loopType: loop.loopType,
|
||||
completed: true,
|
||||
message: `${loop.loopType === 'doWhile' ? 'Do-While' : 'While'} loop completed after ${currentIteration} iterations (condition became false)`,
|
||||
} as Record<string, any>
|
||||
}
|
||||
|
||||
// Only check max iterations for for/forEach loops (while/doWhile have no limit)
|
||||
if (
|
||||
(loop.loopType === 'for' || loop.loopType === 'forEach') &&
|
||||
currentIteration > maxIterations
|
||||
) {
|
||||
logger.info(`Loop ${block.id} has reached maximum iterations (${maxIterations})`)
|
||||
|
||||
return {
|
||||
@@ -142,7 +225,23 @@ export class LoopBlockHandler implements BlockHandler {
|
||||
this.activateChildNodes(block, context, currentIteration)
|
||||
}
|
||||
|
||||
context.loopIterations.set(block.id, currentIteration)
|
||||
// For while/doWhile loops, now that condition is confirmed true, reset child blocks and increment counter
|
||||
if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
|
||||
// Reset all child blocks for this iteration
|
||||
for (const nodeId of loop.nodes || []) {
|
||||
context.executedBlocks.delete(nodeId)
|
||||
context.blockStates.delete(nodeId)
|
||||
context.activeExecutionPath.delete(nodeId)
|
||||
context.decisions.router.delete(nodeId)
|
||||
context.decisions.condition.delete(nodeId)
|
||||
}
|
||||
|
||||
// Increment the counter for the next iteration
|
||||
context.loopIterations.set(block.id, currentIteration + 1)
|
||||
} else {
|
||||
// For for/forEach loops, keep the counter value - it will be managed by the loop manager
|
||||
context.loopIterations.set(block.id, currentIteration)
|
||||
}
|
||||
|
||||
return {
|
||||
loopId: block.id,
|
||||
|
||||
163
apps/sim/executor/handlers/variables/variables-handler.ts
Normal file
163
apps/sim/executor/handlers/variables/variables-handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('VariablesBlockHandler')
|
||||
|
||||
export class VariablesBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
const canHandle = block.metadata?.id === BlockType.VARIABLES
|
||||
logger.info(`VariablesBlockHandler.canHandle: ${canHandle}`, {
|
||||
blockId: block.id,
|
||||
metadataId: block.metadata?.id,
|
||||
expectedType: BlockType.VARIABLES,
|
||||
})
|
||||
return canHandle
|
||||
}
|
||||
|
||||
async execute(
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>,
|
||||
context: ExecutionContext
|
||||
): Promise<BlockOutput> {
|
||||
logger.info(`Executing variables block: ${block.id}`, {
|
||||
blockName: block.metadata?.name,
|
||||
inputsKeys: Object.keys(inputs),
|
||||
variablesInput: inputs.variables,
|
||||
})
|
||||
|
||||
try {
|
||||
// Initialize workflowVariables if not present
|
||||
if (!context.workflowVariables) {
|
||||
context.workflowVariables = {}
|
||||
}
|
||||
|
||||
// Parse variable assignments from the custom input
|
||||
const assignments = this.parseAssignments(inputs.variables)
|
||||
|
||||
// Update context.workflowVariables with new values
|
||||
for (const assignment of assignments) {
|
||||
// Find the variable by ID or name
|
||||
const existingEntry = assignment.variableId
|
||||
? [assignment.variableId, context.workflowVariables[assignment.variableId]]
|
||||
: Object.entries(context.workflowVariables).find(
|
||||
([_, v]) => v.name === assignment.variableName
|
||||
)
|
||||
|
||||
if (existingEntry?.[1]) {
|
||||
// Update existing variable value
|
||||
const [id, variable] = existingEntry
|
||||
context.workflowVariables[id] = {
|
||||
...variable,
|
||||
value: assignment.value,
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Variable "${assignment.variableName}" not found in workflow variables`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Variables updated', {
|
||||
updatedVariables: assignments.map((a) => a.variableName),
|
||||
allVariables: Object.values(context.workflowVariables).map((v: any) => v.name),
|
||||
updatedValues: Object.entries(context.workflowVariables).map(([id, v]: [string, any]) => ({
|
||||
id,
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
})),
|
||||
})
|
||||
|
||||
// Return assignments as a JSON object mapping variable names to values
|
||||
const assignmentsOutput: Record<string, any> = {}
|
||||
for (const assignment of assignments) {
|
||||
assignmentsOutput[assignment.variableName] = assignment.value
|
||||
}
|
||||
|
||||
return {
|
||||
assignments: assignmentsOutput,
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Variables block execution failed:', error)
|
||||
throw new Error(`Variables block execution failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private parseAssignments(
|
||||
assignmentsInput: any
|
||||
): Array<{ variableId?: string; variableName: string; type: string; value: any }> {
|
||||
const result: Array<{ variableId?: string; variableName: string; type: string; value: any }> =
|
||||
[]
|
||||
|
||||
if (!assignmentsInput || !Array.isArray(assignmentsInput)) {
|
||||
return result
|
||||
}
|
||||
|
||||
for (const assignment of assignmentsInput) {
|
||||
if (assignment?.variableName?.trim()) {
|
||||
const name = assignment.variableName.trim()
|
||||
const type = assignment.type || 'string'
|
||||
const value = this.parseValueByType(assignment.value, type)
|
||||
|
||||
result.push({
|
||||
variableId: assignment.variableId,
|
||||
variableName: name,
|
||||
type,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private parseValueByType(value: any, type: string): any {
|
||||
// Handle null/undefined early
|
||||
if (value === null || value === undefined) {
|
||||
if (type === 'number') return 0
|
||||
if (type === 'boolean') return false
|
||||
if (type === 'array') return []
|
||||
if (type === 'object') return {}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Handle plain and string types (plain is for backward compatibility)
|
||||
if (type === 'string' || type === 'plain') {
|
||||
return typeof value === 'string' ? value : String(value)
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const num = Number(value)
|
||||
return Number.isNaN(num) ? 0 : num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
if (type === 'object' || type === 'array') {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return type === 'array' ? [] : {}
|
||||
}
|
||||
}
|
||||
return type === 'array' ? [] : {}
|
||||
}
|
||||
|
||||
// Default: return value as-is
|
||||
return value
|
||||
}
|
||||
}
|
||||
103
apps/sim/executor/handlers/wait/wait-handler.ts
Normal file
103
apps/sim/executor/handlers/wait/wait-handler.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('WaitBlockHandler')
|
||||
|
||||
/**
|
||||
* Helper function to sleep for a specified number of milliseconds
|
||||
* On client-side: checks for cancellation every 100ms (non-blocking for UI)
|
||||
* On server-side: simple sleep without polling (server execution can't be cancelled mid-flight)
|
||||
*/
|
||||
const sleep = async (ms: number, checkCancelled?: () => boolean): Promise<boolean> => {
|
||||
const isClientSide = typeof window !== 'undefined'
|
||||
|
||||
// Server-side: simple sleep without polling
|
||||
if (!isClientSide) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
return true
|
||||
}
|
||||
|
||||
// Client-side: check for cancellation every 100ms
|
||||
const chunkMs = 100
|
||||
let elapsed = 0
|
||||
|
||||
while (elapsed < ms) {
|
||||
// Check if execution was cancelled
|
||||
if (checkCancelled?.()) {
|
||||
return false // Sleep was interrupted
|
||||
}
|
||||
|
||||
// Sleep for a chunk or remaining time, whichever is smaller
|
||||
const sleepTime = Math.min(chunkMs, ms - elapsed)
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepTime))
|
||||
elapsed += sleepTime
|
||||
}
|
||||
|
||||
return true // Sleep completed normally
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Wait blocks that pause workflow execution for a time delay
|
||||
*/
|
||||
export class WaitBlockHandler implements BlockHandler {
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.WAIT
|
||||
}
|
||||
|
||||
async execute(
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>,
|
||||
context: ExecutionContext
|
||||
): Promise<any> {
|
||||
logger.info(`Executing Wait block: ${block.id}`, { inputs })
|
||||
|
||||
// Parse the wait duration
|
||||
const timeValue = Number.parseInt(inputs.timeValue || '10', 10)
|
||||
const timeUnit = inputs.timeUnit || 'seconds'
|
||||
|
||||
// Validate time value
|
||||
if (Number.isNaN(timeValue) || timeValue <= 0) {
|
||||
throw new Error('Wait amount must be a positive number')
|
||||
}
|
||||
|
||||
// Calculate wait time in milliseconds
|
||||
let waitMs = timeValue * 1000 // Default to seconds
|
||||
if (timeUnit === 'minutes') {
|
||||
waitMs = timeValue * 60 * 1000
|
||||
}
|
||||
|
||||
// Enforce 10-minute maximum (600,000 ms)
|
||||
const maxWaitMs = 10 * 60 * 1000
|
||||
if (waitMs > maxWaitMs) {
|
||||
const maxDisplay = timeUnit === 'minutes' ? '10 minutes' : '600 seconds'
|
||||
throw new Error(`Wait time exceeds maximum of ${maxDisplay}`)
|
||||
}
|
||||
|
||||
logger.info(`Waiting for ${waitMs}ms (${timeValue} ${timeUnit})`)
|
||||
|
||||
// Actually sleep for the specified duration
|
||||
// The executor updates context.isCancelled when cancel() is called
|
||||
const checkCancelled = () => {
|
||||
// Check if execution was marked as cancelled in the context
|
||||
return (context as any).isCancelled === true
|
||||
}
|
||||
|
||||
const completed = await sleep(waitMs, checkCancelled)
|
||||
|
||||
if (!completed) {
|
||||
logger.info('Wait was interrupted by cancellation')
|
||||
return {
|
||||
waitDuration: waitMs,
|
||||
status: 'cancelled',
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Wait completed successfully')
|
||||
return {
|
||||
waitDuration: waitMs,
|
||||
status: 'completed',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ResponseBlockHandler,
|
||||
RouterBlockHandler,
|
||||
TriggerBlockHandler,
|
||||
VariablesBlockHandler,
|
||||
WaitBlockHandler,
|
||||
WorkflowBlockHandler,
|
||||
} from '@/executor/handlers'
|
||||
import { LoopManager } from '@/executor/loops/loops'
|
||||
@@ -213,6 +215,8 @@ export class Executor {
|
||||
new ParallelBlockHandler(this.resolver, this.pathTracker),
|
||||
new ResponseBlockHandler(),
|
||||
new WorkflowBlockHandler(),
|
||||
new VariablesBlockHandler(),
|
||||
new WaitBlockHandler(),
|
||||
new GenericBlockHandler(),
|
||||
]
|
||||
|
||||
@@ -1972,10 +1976,11 @@ export class Executor {
|
||||
? forEachItems.length
|
||||
: Object.keys(forEachItems).length
|
||||
}
|
||||
} else {
|
||||
// For regular loops, use the iterations count
|
||||
} else if (loop.loopType === 'for') {
|
||||
// For 'for' loops, use the iterations count
|
||||
iterationTotal = loop.iterations || 5
|
||||
}
|
||||
// For while/doWhile loops, don't set iterationTotal (no max)
|
||||
iterationType = 'loop'
|
||||
}
|
||||
}
|
||||
@@ -2099,10 +2104,11 @@ export class Executor {
|
||||
? forEachItems.length
|
||||
: Object.keys(forEachItems).length
|
||||
}
|
||||
} else {
|
||||
// For regular loops, use the iterations count
|
||||
} else if (loop.loopType === 'for') {
|
||||
// For 'for' loops, use the iterations count
|
||||
iterationTotal = loop.iterations || 5
|
||||
}
|
||||
// For while/doWhile loops, don't set iterationTotal (no max)
|
||||
iterationType = 'loop'
|
||||
}
|
||||
}
|
||||
@@ -2226,10 +2232,11 @@ export class Executor {
|
||||
? forEachItems.length
|
||||
: Object.keys(forEachItems).length
|
||||
}
|
||||
} else {
|
||||
// For regular loops, use the iterations count
|
||||
} else if (loop.loopType === 'for') {
|
||||
// For 'for' loops, use the iterations count
|
||||
iterationTotal = loop.iterations || 5
|
||||
}
|
||||
// For while/doWhile loops, don't set iterationTotal (no max)
|
||||
iterationType = 'loop'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,19 @@ export class LoopManager {
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
|
||||
// For while and doWhile loops, no max iteration limit
|
||||
// They rely on the condition to stop (and workflow timeout as safety)
|
||||
maxIterations = Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
logger.info(`Loop ${loopId} - Current: ${currentIteration}, Max: ${maxIterations}`)
|
||||
|
||||
// Check if we've completed all iterations
|
||||
if (currentIteration >= maxIterations) {
|
||||
// Check if we've completed all iterations (only for for/forEach loops)
|
||||
if (
|
||||
currentIteration >= maxIterations &&
|
||||
(loop.loopType === 'for' || loop.loopType === 'forEach')
|
||||
) {
|
||||
hasLoopReachedMaxIterations = true
|
||||
logger.info(`Loop ${loopId} has completed all ${maxIterations} iterations`)
|
||||
|
||||
@@ -131,15 +138,21 @@ export class LoopManager {
|
||||
|
||||
logger.info(`Loop ${loopId} - Completed and activated end connections`)
|
||||
} else {
|
||||
context.loopIterations.set(loopId, currentIteration + 1)
|
||||
logger.info(`Loop ${loopId} - Incremented counter to ${currentIteration + 1}`)
|
||||
// For while/doWhile loops, DON'T reset yet - let the loop handler check the condition first
|
||||
// The loop handler will decide whether to continue or exit based on the condition
|
||||
if (loop.loopType === 'while' || loop.loopType === 'doWhile') {
|
||||
// Just reset the loop block itself so it can re-evaluate the condition
|
||||
context.executedBlocks.delete(loopId)
|
||||
context.blockStates.delete(loopId)
|
||||
} else {
|
||||
// For for/forEach loops, increment and reset everything as usual
|
||||
context.loopIterations.set(loopId, currentIteration + 1)
|
||||
|
||||
this.resetLoopBlocks(loopId, loop, context)
|
||||
this.resetLoopBlocks(loopId, loop, context)
|
||||
|
||||
context.executedBlocks.delete(loopId)
|
||||
context.blockStates.delete(loopId)
|
||||
|
||||
logger.info(`Loop ${loopId} - Reset for iteration ${currentIteration + 1}`)
|
||||
context.executedBlocks.delete(loopId)
|
||||
context.blockStates.delete(loopId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,12 +229,20 @@ describe('PathTracker', () => {
|
||||
})
|
||||
|
||||
describe('loop blocks', () => {
|
||||
it('should only activate loop-start connections', () => {
|
||||
it('should activate loop-start connections when loop is not completed', () => {
|
||||
pathTracker.updateExecutionPaths(['loop1'], mockContext)
|
||||
|
||||
expect(mockContext.activeExecutionPath.has('block1')).toBe(true)
|
||||
expect(mockContext.activeExecutionPath.has('block2')).toBe(false)
|
||||
})
|
||||
|
||||
it('should not activate loop-start connections when loop is completed', () => {
|
||||
mockContext.completedLoops.add('loop1')
|
||||
pathTracker.updateExecutionPaths(['loop1'], mockContext)
|
||||
|
||||
expect(mockContext.activeExecutionPath.has('block1')).toBe(false)
|
||||
expect(mockContext.activeExecutionPath.has('block2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regular blocks', () => {
|
||||
|
||||
@@ -286,13 +286,18 @@ export class PathTracker {
|
||||
* Update paths for loop blocks
|
||||
*/
|
||||
private updateLoopPaths(block: SerializedBlock, context: ExecutionContext): void {
|
||||
// Don't activate loop-start connections if the loop has completed
|
||||
// (e.g., while loop condition is false)
|
||||
if (context.completedLoops.has(block.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const outgoingConnections = this.getOutgoingConnections(block.id)
|
||||
|
||||
for (const conn of outgoingConnections) {
|
||||
// Only activate loop-start connections
|
||||
if (conn.sourceHandle === 'loop-start-source') {
|
||||
context.activeExecutionPath.add(conn.target)
|
||||
logger.info(`Loop ${block.id} activated start path to: ${conn.target}`)
|
||||
}
|
||||
// loop-end-source connections will be activated by the loop manager
|
||||
}
|
||||
|
||||
@@ -340,8 +340,11 @@ export function useCollaborativeWorkflow() {
|
||||
if (config.iterations !== undefined) {
|
||||
workflowStore.updateLoopCount(payload.id, config.iterations)
|
||||
}
|
||||
// Handle both forEach items and while conditions
|
||||
if (config.forEachItems !== undefined) {
|
||||
workflowStore.updateLoopCollection(payload.id, config.forEachItems)
|
||||
} else if (config.whileCondition !== undefined) {
|
||||
workflowStore.updateLoopCollection(payload.id, config.whileCondition)
|
||||
}
|
||||
} else if (payload.type === 'parallel') {
|
||||
const { config } = payload
|
||||
@@ -1261,7 +1264,7 @@ export function useCollaborativeWorkflow() {
|
||||
)
|
||||
|
||||
const collaborativeUpdateLoopType = useCallback(
|
||||
(loopId: string, loopType: 'for' | 'forEach') => {
|
||||
(loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => {
|
||||
const currentBlock = workflowStore.blocks[loopId]
|
||||
if (!currentBlock || currentBlock.type !== 'loop') return
|
||||
|
||||
@@ -1271,13 +1274,20 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
const currentIterations = currentBlock.data?.count || 5
|
||||
const currentCollection = currentBlock.data?.collection || ''
|
||||
const currentCondition = currentBlock.data?.whileCondition || ''
|
||||
|
||||
const config = {
|
||||
const config: any = {
|
||||
id: loopId,
|
||||
nodes: childNodes,
|
||||
iterations: currentIterations,
|
||||
loopType,
|
||||
forEachItems: currentCollection,
|
||||
}
|
||||
|
||||
// Include the appropriate field based on loop type
|
||||
if (loopType === 'forEach') {
|
||||
config.forEachItems = currentCollection
|
||||
} else if (loopType === 'while' || loopType === 'doWhile') {
|
||||
config.whileCondition = currentCondition
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () =>
|
||||
@@ -1386,12 +1396,18 @@ export function useCollaborativeWorkflow() {
|
||||
const currentIterations = currentBlock.data?.count || 5
|
||||
const currentLoopType = currentBlock.data?.loopType || 'for'
|
||||
|
||||
const config = {
|
||||
const config: any = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
iterations: currentIterations,
|
||||
loopType: currentLoopType,
|
||||
forEachItems: collection,
|
||||
}
|
||||
|
||||
// Add the appropriate field based on loop type
|
||||
if (currentLoopType === 'forEach') {
|
||||
config.forEachItems = collection
|
||||
} else if (currentLoopType === 'while') {
|
||||
config.whileCondition = collection
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () =>
|
||||
|
||||
@@ -882,8 +882,9 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
loopType: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: ['for', 'forEach'],
|
||||
description: "Loop Type - 'for' runs N times, 'forEach' iterates over collection",
|
||||
enum: ['for', 'forEach', 'while', 'doWhile'],
|
||||
description:
|
||||
"Loop Type - 'for' runs N times, 'forEach' iterates over collection, 'while' runs while condition is true, 'doWhile' runs at least once then checks condition",
|
||||
},
|
||||
iterations: {
|
||||
type: 'number',
|
||||
@@ -899,6 +900,12 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
description: "Collection to iterate over (for 'forEach' loopType)",
|
||||
example: '<previousblock.items>',
|
||||
},
|
||||
condition: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: "Condition to evaluate (for 'while' and 'doWhile' loopType)",
|
||||
example: '<loop.currentIteration> < 10',
|
||||
},
|
||||
maxConcurrency: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
@@ -924,6 +931,8 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
options: [
|
||||
{ label: 'For Loop (count)', id: 'for' },
|
||||
{ label: 'For Each (collection)', id: 'forEach' },
|
||||
{ label: 'While (condition)', id: 'while' },
|
||||
{ label: 'Do While (condition)', id: 'doWhile' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -942,6 +951,14 @@ const SPECIAL_BLOCKS_METADATA: Record<string, any> = {
|
||||
placeholder: 'Array or object to iterate over...',
|
||||
condition: { field: 'loopType', value: 'forEach' },
|
||||
},
|
||||
{
|
||||
id: 'condition',
|
||||
title: 'Condition',
|
||||
type: 'code',
|
||||
language: 'javascript',
|
||||
placeholder: '<counter.value> < 10',
|
||||
condition: { field: 'loopType', value: ['while', 'doWhile'] },
|
||||
},
|
||||
{
|
||||
id: 'maxConcurrency',
|
||||
title: 'Max Concurrency',
|
||||
|
||||
@@ -448,6 +448,8 @@ function applyOperationsToWorkflowState(
|
||||
block.data.count = params.inputs.iterations
|
||||
if (params.inputs.collection !== undefined)
|
||||
block.data.collection = params.inputs.collection
|
||||
if (params.inputs.condition !== undefined)
|
||||
block.data.whileCondition = params.inputs.condition
|
||||
} else if (block.type === 'parallel') {
|
||||
block.data = block.data || {}
|
||||
if (params.inputs.parallelType !== undefined)
|
||||
@@ -510,6 +512,7 @@ function applyOperationsToWorkflowState(
|
||||
if (params.inputs?.loopType) block.data.loopType = params.inputs.loopType
|
||||
if (params.inputs?.iterations) block.data.count = params.inputs.iterations
|
||||
if (params.inputs?.collection) block.data.collection = params.inputs.collection
|
||||
if (params.inputs?.condition) block.data.whileCondition = params.inputs.condition
|
||||
} else if (block.type === 'parallel') {
|
||||
block.data = block.data || {}
|
||||
if (params.inputs?.parallelType) block.data.parallelType = params.inputs.parallelType
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Environment utility functions for consistent environment detection across the application
|
||||
*/
|
||||
import { env, getEnv, isTruthy } from './env'
|
||||
import { env, isTruthy } from './env'
|
||||
|
||||
/**
|
||||
* Is the application running in production mode
|
||||
@@ -21,9 +21,9 @@ export const isTest = env.NODE_ENV === 'test'
|
||||
/**
|
||||
* Is this the hosted version of the application
|
||||
*/
|
||||
export const isHosted =
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
export const isHosted = true
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' ||
|
||||
// getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai'
|
||||
|
||||
/**
|
||||
* Is billing enforcement enabled
|
||||
|
||||
@@ -25,7 +25,9 @@ export interface Loop {
|
||||
id: string
|
||||
nodes: string[]
|
||||
iterations: number
|
||||
loopType: 'for' | 'forEach'
|
||||
loopType: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
forEachItems?: any[] | Record<string, any> | string // Items or expression
|
||||
whileCondition?: string // JS expression that evaluates to boolean
|
||||
}
|
||||
|
||||
export interface Parallel {
|
||||
|
||||
@@ -180,10 +180,14 @@ export async function loadWorkflowFromNormalizedTables(
|
||||
iterations:
|
||||
typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1,
|
||||
loopType:
|
||||
(config as Loop).loopType === 'for' || (config as Loop).loopType === 'forEach'
|
||||
(config as Loop).loopType === 'for' ||
|
||||
(config as Loop).loopType === 'forEach' ||
|
||||
(config as Loop).loopType === 'while' ||
|
||||
(config as Loop).loopType === 'doWhile'
|
||||
? (config as Loop).loopType
|
||||
: 'for',
|
||||
forEachItems: (config as Loop).forEachItems ?? '',
|
||||
whileCondition: (config as Loop).whileCondition ?? undefined,
|
||||
}
|
||||
loops[subflow.id] = loop
|
||||
} else if (subflow.type === SUBFLOW_TYPES.PARALLEL) {
|
||||
|
||||
@@ -309,6 +309,7 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
|
||||
if (block.data?.loopType) loopInputs.loopType = block.data.loopType
|
||||
if (block.data?.count !== undefined) loopInputs.iterations = block.data.count
|
||||
if (block.data?.collection !== undefined) loopInputs.collection = block.data.collection
|
||||
if (block.data?.whileCondition !== undefined) loopInputs.condition = block.data.whileCondition
|
||||
if (block.data?.parallelType) loopInputs.parallelType = block.data.parallelType
|
||||
inputs = loopInputs
|
||||
} else {
|
||||
|
||||
@@ -44,8 +44,9 @@ export interface SerializedLoop {
|
||||
id: string
|
||||
nodes: string[]
|
||||
iterations: number
|
||||
loopType?: 'for' | 'forEach' | 'while'
|
||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
forEachItems?: any[] | Record<string, any> | string // Items to iterate over or expression to evaluate
|
||||
whileCondition?: string // JS expression that evaluates to boolean (for while and doWhile loops)
|
||||
}
|
||||
|
||||
export interface SerializedParallel {
|
||||
|
||||
@@ -298,7 +298,10 @@ async function handleBlockOperationTx(
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||
loopType: payload.data?.loopType || 'for',
|
||||
forEachItems: payload.data?.collection || '',
|
||||
// Set the appropriate field based on loop type
|
||||
...(payload.data?.loopType === 'while' || payload.data?.loopType === 'doWhile'
|
||||
? { whileCondition: payload.data?.whileCondition || '' }
|
||||
: { forEachItems: payload.data?.collection || '' }),
|
||||
}
|
||||
: {
|
||||
id: payload.id,
|
||||
@@ -721,7 +724,10 @@ async function handleBlockOperationTx(
|
||||
nodes: [], // Empty initially, will be populated when child blocks are added
|
||||
iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||
loopType: payload.data?.loopType || 'for',
|
||||
forEachItems: payload.data?.collection || '',
|
||||
// Set the appropriate field based on loop type
|
||||
...(payload.data?.loopType === 'while' || payload.data?.loopType === 'doWhile'
|
||||
? { whileCondition: payload.data?.whileCondition || '' }
|
||||
: { forEachItems: payload.data?.collection || '' }),
|
||||
}
|
||||
: {
|
||||
id: payload.id,
|
||||
@@ -856,18 +862,27 @@ async function handleSubflowOperationTx(
|
||||
// Also update the corresponding block's data to keep UI in sync
|
||||
if (payload.type === 'loop' && payload.config.iterations !== undefined) {
|
||||
// Update the loop block's data.count property
|
||||
const blockData: any = {
|
||||
count: payload.config.iterations,
|
||||
loopType: payload.config.loopType,
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
}
|
||||
|
||||
// Add the appropriate field based on loop type
|
||||
if (payload.config.loopType === 'while' || payload.config.loopType === 'doWhile') {
|
||||
// For while and doWhile loops, use whileCondition
|
||||
blockData.whileCondition = payload.config.whileCondition || ''
|
||||
} else {
|
||||
// For for/forEach loops, use collection (block data) which maps to forEachItems (loops store)
|
||||
blockData.collection = payload.config.forEachItems || ''
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
data: {
|
||||
...payload.config,
|
||||
count: payload.config.iterations,
|
||||
loopType: payload.config.loopType,
|
||||
collection: payload.config.forEachItems,
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
data: blockData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
|
||||
@@ -764,7 +764,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
}
|
||||
}),
|
||||
|
||||
updateLoopType: (loopId: string, loopType: 'for' | 'forEach') =>
|
||||
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') =>
|
||||
set((state) => {
|
||||
const block = state.blocks[loopId]
|
||||
if (!block || block.type !== 'loop') return state
|
||||
@@ -792,14 +792,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const block = state.blocks[loopId]
|
||||
if (!block || block.type !== 'loop') return state
|
||||
|
||||
const loopType = block.data?.loopType || 'for'
|
||||
|
||||
// Update the appropriate field based on loop type
|
||||
const dataUpdate: any = { ...block.data }
|
||||
if (loopType === 'while' || loopType === 'doWhile') {
|
||||
dataUpdate.whileCondition = collection
|
||||
} else {
|
||||
dataUpdate.collection = collection
|
||||
}
|
||||
|
||||
const newBlocks = {
|
||||
...state.blocks,
|
||||
[loopId]: {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
collection,
|
||||
},
|
||||
data: dataUpdate,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ export function isValidSubflowType(type: string): type is SubflowType {
|
||||
export interface LoopConfig {
|
||||
nodes: string[]
|
||||
iterations: number
|
||||
loopType: 'for' | 'forEach'
|
||||
loopType: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
forEachItems?: any[] | Record<string, any> | string
|
||||
whileCondition?: string // JS expression that evaluates to boolean
|
||||
}
|
||||
|
||||
export interface ParallelConfig {
|
||||
@@ -50,9 +51,10 @@ export interface BlockData {
|
||||
height?: number
|
||||
|
||||
// Loop-specific properties
|
||||
collection?: any // The items to iterate over in a loop
|
||||
collection?: any // The items to iterate over in a forEach loop
|
||||
count?: number // Number of iterations for numeric loops
|
||||
loopType?: 'for' | 'forEach' // Type of loop - must match Loop interface
|
||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile' // Type of loop - must match Loop interface
|
||||
whileCondition?: string // While/DoWhile loop condition (JS expression)
|
||||
|
||||
// Parallel-specific properties
|
||||
parallelType?: 'collection' | 'count' // Type of parallel execution
|
||||
@@ -121,8 +123,9 @@ export interface Loop {
|
||||
id: string
|
||||
nodes: string[]
|
||||
iterations: number
|
||||
loopType: 'for' | 'forEach'
|
||||
loopType: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||
forEachItems?: any[] | Record<string, any> | string // Items or expression
|
||||
whileCondition?: string // JS expression that evaluates to boolean
|
||||
}
|
||||
|
||||
export interface Parallel {
|
||||
@@ -194,7 +197,7 @@ export interface WorkflowActions {
|
||||
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
|
||||
triggerUpdate: () => void
|
||||
updateLoopCount: (loopId: string, count: number) => void
|
||||
updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => void
|
||||
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
|
||||
updateLoopCollection: (loopId: string, collection: string) => void
|
||||
updateParallelCount: (parallelId: string, count: number) => void
|
||||
updateParallelCollection: (parallelId: string, collection: string) => void
|
||||
|
||||
@@ -16,27 +16,38 @@ export function convertLoopBlockToLoop(
|
||||
const loopBlock = blocks[loopBlockId]
|
||||
if (!loopBlock || loopBlock.type !== 'loop') return undefined
|
||||
|
||||
// Parse collection if it's a string representation of an array/object
|
||||
let forEachItems: any = loopBlock.data?.collection || ''
|
||||
if (typeof forEachItems === 'string' && forEachItems.trim()) {
|
||||
const trimmed = forEachItems.trim()
|
||||
// Try to parse if it looks like JSON
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
try {
|
||||
forEachItems = JSON.parse(trimmed)
|
||||
} catch {
|
||||
// Keep as string if parsing fails - will be evaluated at runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
const loopType = loopBlock.data?.loopType || 'for'
|
||||
|
||||
return {
|
||||
const loop: Loop = {
|
||||
id: loopBlockId,
|
||||
nodes: findChildNodes(loopBlockId, blocks),
|
||||
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
|
||||
loopType: loopBlock.data?.loopType || 'for',
|
||||
forEachItems,
|
||||
loopType,
|
||||
}
|
||||
|
||||
// Set the appropriate field based on loop type
|
||||
if (loopType === 'while' || loopType === 'doWhile') {
|
||||
// For while and doWhile loops, use whileCondition
|
||||
loop.whileCondition = loopBlock.data?.whileCondition || ''
|
||||
} else {
|
||||
// For for/forEach loops, read from collection (block data) and map to forEachItems (loops store)
|
||||
// Parse collection if it's a string representation of an array/object
|
||||
let forEachItems: any = loopBlock.data?.collection || ''
|
||||
if (typeof forEachItems === 'string' && forEachItems.trim()) {
|
||||
const trimmed = forEachItems.trim()
|
||||
// Try to parse if it looks like JSON
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
try {
|
||||
forEachItems = JSON.parse(trimmed)
|
||||
} catch {
|
||||
// Keep as string if parsing fails - will be evaluated at runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
loop.forEachItems = forEachItems
|
||||
}
|
||||
|
||||
return loop
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user