v0.5.52: new port-based router block, combobox expression and variable support

This commit is contained in:
Waleed
2026-01-06 16:14:10 -08:00
committed by GitHub
16 changed files with 1154 additions and 133 deletions

View File

@@ -157,7 +157,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
{formattedContent && !formattedContent.startsWith('Uploaded') && (
<div className='rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] px-[8px] py-[6px] transition-all duration-200'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-gray-100 text-sm leading-[1.25rem]'>
<div className='whitespace-pre-wrap break-words font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} />
</div>
</div>
@@ -168,7 +168,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
return (
<div className='w-full max-w-full overflow-hidden pl-[2px] opacity-100 transition-opacity duration-200'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[#E8E8E8] text-sm leading-[1.25rem]'>
<div className='whitespace-pre-wrap break-words font-[470] font-season text-[var(--text-primary)] text-sm leading-[1.25rem]'>
<WordWrap text={formattedContent} />
{message.isStreaming && <StreamingIndicator />}
</div>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow } from 'reactflow'
import { Combobox, type ComboboxOption } from '@/components/emcn/components'
import { cn } from '@/lib/core/utils/cn'
@@ -7,6 +7,9 @@ import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workfl
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
/**
* Constants for ComboBox component behavior
@@ -48,6 +51,19 @@ interface ComboBoxProps {
placeholder?: string
/** Configuration for the sub-block */
config: SubBlockConfig
/** Async function to fetch options dynamically */
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
}
export function ComboBox({
@@ -61,23 +77,89 @@ export function ComboBox({
disabled,
placeholder = 'Type or select an option...',
config,
fetchOptions,
fetchOptionById,
dependsOn,
}: ComboBoxProps) {
// Hooks and context
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const reactFlowInstance = useReactFlow()
// Dependency tracking for fetchOptions
const dependsOnFields = useMemo(() => getDependsOnFields(dependsOn), [dependsOn])
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const dependencyValues = useSubBlockStore(
useCallback(
(state) => {
if (dependsOnFields.length === 0 || !activeWorkflowId) return []
const workflowValues = state.workflowValues[activeWorkflowId] || {}
const blockValues = workflowValues[blockId] || {}
return dependsOnFields.map((depKey) => blockValues[depKey] ?? null)
},
[dependsOnFields, activeWorkflowId, blockId]
)
)
// State management
const [storeInitialized, setStoreInitialized] = useState(false)
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousDependencyValuesRef = useRef<string>('')
/**
* Fetches options from the async fetchOptions function if provided
*/
const fetchOptionsIfNeeded = useCallback(async () => {
if (!fetchOptions || isPreview || disabled) return
setIsLoadingOptions(true)
setFetchError(null)
try {
const options = await fetchOptions(blockId, subBlockId)
setFetchedOptions(options)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options'
setFetchError(errorMessage)
setFetchedOptions([])
} finally {
setIsLoadingOptions(false)
}
}, [fetchOptions, blockId, subBlockId, isPreview, disabled])
// Determine the active value based on mode (preview vs. controlled vs. store)
const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue
// Evaluate options if provided as a function
const evaluatedOptions = useMemo(() => {
// Evaluate static options if provided as a function
const staticOptions = useMemo(() => {
return typeof options === 'function' ? options() : options
}, [options])
// Normalize fetched options to match ComboBoxOption format
const normalizedFetchedOptions = useMemo((): ComboBoxOption[] => {
return fetchedOptions.map((opt) => ({ label: opt.label, id: opt.id }))
}, [fetchedOptions])
// Merge static and fetched options - fetched options take priority when available
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
let opts: ComboBoxOption[] =
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
}
return opts
}, [fetchOptions, normalizedFetchedOptions, staticOptions, hydratedOption])
// Convert options to Combobox format
const comboboxOptions = useMemo((): ComboboxOption[] => {
return evaluatedOptions.map((option) => {
@@ -160,6 +242,94 @@ export function ComboBox({
}
}, [storeInitialized, value, defaultOptionValue, setStoreValue])
// Clear fetched options and hydrated option when dependencies change
useEffect(() => {
if (fetchOptions && dependsOnFields.length > 0) {
const currentDependencyValuesStr = JSON.stringify(dependencyValues)
const previousDependencyValuesStr = previousDependencyValuesRef.current
if (
previousDependencyValuesStr &&
currentDependencyValuesStr !== previousDependencyValuesStr
) {
setFetchedOptions([])
setHydratedOption(null)
}
previousDependencyValuesRef.current = currentDependencyValuesStr
}
}, [dependencyValues, fetchOptions, dependsOnFields.length])
// Fetch options when needed (on mount, when enabled, or when dependencies change)
useEffect(() => {
if (
fetchOptions &&
!isPreview &&
!disabled &&
fetchedOptions.length === 0 &&
!isLoadingOptions &&
!fetchError
) {
fetchOptionsIfNeeded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [
fetchOptions,
isPreview,
disabled,
fetchedOptions.length,
isLoadingOptions,
fetchError,
dependencyValues,
])
// Hydrate the stored value's label by fetching it individually
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
const valueToHydrate = value as string | null | undefined
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = staticOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
value,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
staticOptions,
hydratedOption?.id,
])
/**
* Handles wheel event for ReactFlow zoom control
* Intercepts Ctrl/Cmd+Wheel to zoom the canvas
@@ -247,11 +417,13 @@ export function ComboBox({
return option.id === newValue
})
if (!matchedOption) {
return
}
const nextValue = typeof matchedOption === 'string' ? matchedOption : matchedOption.id
// If a matching option is found, store its ID; otherwise store the raw value
// (allows expressions like <block.output> to be entered directly)
const nextValue = matchedOption
? typeof matchedOption === 'string'
? matchedOption
: matchedOption.id
: newValue
setStoreValue(nextValue)
}}
isPreview={isPreview}
@@ -293,6 +465,13 @@ export function ComboBox({
onWheel: handleWheel,
autoComplete: 'off',
}}
isLoading={isLoadingOptions}
error={fetchError}
onOpenChange={(open) => {
if (open) {
void fetchOptionsIfNeeded()
}
}}
/>
)}
</SubBlockInputController>

View File

@@ -12,6 +12,7 @@ import {
getCodeEditorProps,
highlight,
languages,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
@@ -74,6 +75,8 @@ interface ConditionInputProps {
previewValue?: string | null
/** Whether the component is disabled */
disabled?: boolean
/** Mode: 'condition' for code editor, 'router' for text input */
mode?: 'condition' | 'router'
}
/**
@@ -101,7 +104,9 @@ export function ConditionInput({
isPreview = false,
previewValue,
disabled = false,
mode = 'condition',
}: ConditionInputProps) {
const isRouterMode = mode === 'router'
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
@@ -161,32 +166,50 @@ export function ConditionInput({
const shouldPersistRef = useRef<boolean>(false)
/**
* Creates default if/else conditional blocks with stable IDs.
* Creates default blocks with stable IDs.
* For conditions: if/else blocks. For router: one route block.
*
* @returns Array of two default blocks (if and else)
* @returns Array of default blocks
*/
const createDefaultBlocks = (): ConditionalBlock[] => [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
const createDefaultBlocks = (): ConditionalBlock[] => {
if (isRouterMode) {
return [
{
id: generateStableId(blockId, 'route1'),
title: 'route1',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
return [
{
id: generateStableId(blockId, 'if'),
title: 'if',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
{
id: generateStableId(blockId, 'else'),
title: 'else',
value: '',
showTags: false,
showEnvVars: false,
searchTerm: '',
cursorPosition: 0,
activeSourceBlockId: null,
},
]
}
// Initialize with a loading state instead of default blocks
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
@@ -270,10 +293,13 @@ export function ConditionInput({
const parsedBlocks = safeParseJSON(effectiveValueStr)
if (parsedBlocks) {
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
// For router mode, keep original titles. For condition mode, assign if/else if/else
const blocksWithCorrectTitles = isRouterMode
? parsedBlocks
: parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
setConditionalBlocks(blocksWithCorrectTitles)
hasInitializedRef.current = true
@@ -573,12 +599,17 @@ export function ConditionInput({
/**
* Updates block titles based on their position in the array.
* First block is always 'if', last is 'else', middle ones are 'else if'.
* For conditions: First block is 'if', last is 'else', middle ones are 'else if'.
* For router: Titles are user-editable and not auto-updated.
*
* @param blocks - Array of conditional blocks
* @returns Updated blocks with correct titles
*/
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
if (isRouterMode) {
// For router mode, don't change titles - they're user-editable
return blocks
}
return blocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
@@ -590,13 +621,15 @@ export function ConditionInput({
if (isPreview || disabled) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
if (conditionalBlocks[blockIndex]?.title === 'else') return
if (!isRouterMode && conditionalBlocks[blockIndex]?.title === 'else') return
const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`)
const newBlockId = isRouterMode
? generateStableId(blockId, `route-${Date.now()}`)
: generateStableId(blockId, `else-if-${Date.now()}`)
const newBlock: ConditionalBlock = {
id: newBlockId,
title: '',
title: isRouterMode ? `route-${Date.now()}` : '',
value: '',
showTags: false,
showEnvVars: false,
@@ -710,13 +743,15 @@ export function ConditionInput({
<div
className={cn(
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
isRouterMode
? 'rounded-t-[4px] border-[var(--border-1)] border-b'
: block.title === 'else'
? 'rounded-[4px] border-0'
: 'rounded-t-[4px] border-[var(--border-1)] border-b'
)}
>
<span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
{block.title}
{isRouterMode ? `Route ${index + 1}` : block.title}
</span>
<div className='flex items-center gap-[8px]'>
<Tooltip.Root>
@@ -724,7 +759,7 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => addBlock(block.id)}
disabled={isPreview || disabled || block.title === 'else'}
disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
className='h-auto p-0'
>
<Plus className='h-[14px] w-[14px]' />
@@ -739,7 +774,12 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => moveBlock(block.id, 'up')}
disabled={isPreview || index === 0 || disabled || block.title === 'else'}
disabled={
isPreview ||
index === 0 ||
disabled ||
(!isRouterMode && block.title === 'else')
}
className='h-auto p-0'
>
<ChevronUp className='h-[14px] w-[14px]' />
@@ -758,8 +798,8 @@ export function ConditionInput({
isPreview ||
disabled ||
index === conditionalBlocks.length - 1 ||
conditionalBlocks[index + 1]?.title === 'else' ||
block.title === 'else'
(!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
(!isRouterMode && block.title === 'else')
}
className='h-auto p-0'
>
@@ -775,18 +815,122 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => removeBlock(block.id)}
disabled={isPreview || conditionalBlocks.length === 1 || disabled}
disabled={isPreview || disabled || conditionalBlocks.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Block</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete Condition</Tooltip.Content>
<Tooltip.Content>
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{block.title !== 'else' &&
{/* Router mode: show description textarea with tag/env var support */}
{isRouterMode && (
<div
className='relative'
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(block.id, e)}
>
<Textarea
data-router-block-id={block.id}
value={block.value}
onChange={(e) => {
if (!isPreview && !disabled) {
const newValue = e.target.value
const pos = e.target.selectionStart ?? 0
const tagTrigger = checkTagTrigger(newValue, pos)
const envVarTrigger = checkEnvVarTrigger(newValue, pos)
shouldPersistRef.current = true
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
value: newValue,
showTags: tagTrigger.show,
showEnvVars: envVarTrigger.show,
searchTerm: envVarTrigger.show ? envVarTrigger.searchTerm : '',
cursorPosition: pos,
}
: b
)
)
}
}}
onBlur={() => {
setTimeout(() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id ? { ...b, showTags: false, showEnvVars: false } : b
)
)
}, 150)
}}
placeholder='Describe when this route should be taken...'
disabled={disabled || isPreview}
className='min-h-[60px] resize-none rounded-none border-0 px-3 py-2 text-sm placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
rows={2}
/>
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
searchTerm={block.searchTerm}
inputValue={block.value}
cursorPosition={block.cursorPosition}
workspaceId={workspaceId}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showEnvVars: false,
searchTerm: '',
}
: b
)
)
}}
/>
)}
{block.showTags && (
<TagDropdown
visible={block.showTags}
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
blockId={blockId}
activeSourceBlockId={block.activeSourceBlockId}
inputValue={block.value}
cursorPosition={block.cursorPosition}
onClose={() => {
setConditionalBlocks((blocks) =>
blocks.map((b) =>
b.id === block.id
? {
...b,
showTags: false,
activeSourceBlockId: null,
}
: b
)
)
}}
/>
)}
</div>
)}
{/* Condition mode: show code editor */}
{!isRouterMode &&
block.title !== 'else' &&
(() => {
const blockLineCount = block.value.split('\n').length
const blockGutterWidth = calculateGutterWidth(blockLineCount)

View File

@@ -44,6 +44,12 @@ interface DropdownProps {
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
/** Enable search input in dropdown */
@@ -71,6 +77,7 @@ export function Dropdown({
placeholder = 'Select an option...',
multiSelect = false,
fetchOptions,
fetchOptionById,
dependsOn,
searchable = false,
}: DropdownProps) {
@@ -98,6 +105,7 @@ export function Dropdown({
const [fetchedOptions, setFetchedOptions] = useState<Array<{ label: string; id: string }>>([])
const [isLoadingOptions, setIsLoadingOptions] = useState(false)
const [fetchError, setFetchError] = useState<string | null>(null)
const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null)
const previousModeRef = useRef<string | null>(null)
const previousDependencyValuesRef = useRef<string>('')
@@ -150,11 +158,23 @@ export function Dropdown({
}, [fetchedOptions])
const availableOptions = useMemo(() => {
if (fetchOptions && normalizedFetchedOptions.length > 0) {
return normalizedFetchedOptions
let opts: DropdownOption[] =
fetchOptions && normalizedFetchedOptions.length > 0
? normalizedFetchedOptions
: evaluatedOptions
// Merge hydrated option if not already present
if (hydratedOption) {
const alreadyPresent = opts.some((o) =>
typeof o === 'string' ? o === hydratedOption.id : o.id === hydratedOption.id
)
if (!alreadyPresent) {
opts = [hydratedOption, ...opts]
}
}
return evaluatedOptions
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions])
return opts
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
/**
* Convert dropdown options to Combobox format
@@ -310,7 +330,7 @@ export function Dropdown({
)
/**
* Effect to clear fetched options when dependencies actually change
* Effect to clear fetched options and hydrated option when dependencies actually change
* This ensures options are refetched with new dependency values (e.g., new credentials)
*/
useEffect(() => {
@@ -323,6 +343,7 @@ export function Dropdown({
currentDependencyValuesStr !== previousDependencyValuesStr
) {
setFetchedOptions([])
setHydratedOption(null)
}
previousDependencyValuesRef.current = currentDependencyValuesStr
@@ -338,18 +359,72 @@ export function Dropdown({
!isPreview &&
!disabled &&
fetchedOptions.length === 0 &&
!isLoadingOptions
!isLoadingOptions &&
!fetchError
) {
fetchOptionsIfNeeded()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchOptionsIfNeeded deps already covered above
}, [
fetchOptions,
isPreview,
disabled,
fetchedOptions.length,
isLoadingOptions,
fetchOptionsIfNeeded,
dependencyValues, // Refetch when dependencies change
fetchError,
dependencyValues,
])
/**
* Effect to hydrate the stored value's label by fetching it individually
* This ensures the correct label is shown before the full options list loads
*/
useEffect(() => {
if (!fetchOptionById || isPreview || disabled) return
// Get the value to hydrate (single value only, not multi-select)
const valueToHydrate = multiSelect ? null : (singleValue as string | null | undefined)
if (!valueToHydrate) return
// Skip if value is an expression (not a real ID)
if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return
// Skip if already hydrated with the same value
if (hydratedOption?.id === valueToHydrate) return
// Skip if value is already in fetched options or static options
const alreadyInFetchedOptions = fetchedOptions.some((opt) => opt.id === valueToHydrate)
const alreadyInStaticOptions = evaluatedOptions.some((opt) =>
typeof opt === 'string' ? opt === valueToHydrate : opt.id === valueToHydrate
)
if (alreadyInFetchedOptions || alreadyInStaticOptions) return
// Track if effect is still active (cleanup on unmount or value change)
let isActive = true
// Fetch the hydrated option
fetchOptionById(blockId, subBlockId, valueToHydrate)
.then((option) => {
if (isActive) setHydratedOption(option)
})
.catch(() => {
if (isActive) setHydratedOption(null)
})
return () => {
isActive = false
}
}, [
fetchOptionById,
singleValue,
multiSelect,
blockId,
subBlockId,
isPreview,
disabled,
fetchedOptions,
evaluatedOptions,
hydratedOption?.id,
])
/**

View File

@@ -460,6 +460,7 @@ function SubBlockComponent({
disabled={isDisabled}
multiSelect={config.multiSelect}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
searchable={config.searchable}
/>
@@ -479,6 +480,9 @@ function SubBlockComponent({
previewValue={previewValue as any}
disabled={isDisabled}
config={config}
fetchOptions={config.fetchOptions}
fetchOptionById={config.fetchOptionById}
dependsOn={config.dependsOn}
/>
</div>
)
@@ -605,6 +609,18 @@ function SubBlockComponent({
/>
)
case 'router-input':
return (
<ConditionInput
blockId={blockId}
subBlockId={config.id}
isPreview={isPreview}
previewValue={previewValue as any}
disabled={isDisabled}
mode='router'
/>
)
case 'eval-input':
return (
<EvalInput

View File

@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
]
}, [type, subBlockState, id])
/**
* Compute per-route rows (id/value) for router_v2 blocks so we can render
* one row per route with its own output handle.
* Uses same structure as conditions: { id, title, value }
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] as { id: string; value: string }[]
const routesValue = subBlockState.routes?.value
const raw = typeof routesValue === 'string' ? routesValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `${id}-route-${index}`,
value: routeItem?.value ?? '',
}
})
}
}
} catch (error) {
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
return [{ id: `${id}-route-route1`, value: '' }]
}, [type, subBlockState, id])
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -857,6 +888,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
// +1 for context row, plus route rows
rowsCount = 1 + routerRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
@@ -879,6 +913,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
routerRows.length,
horizontalHandles,
],
})
@@ -1073,32 +1108,45 @@ export const WorkflowBlock = memo(function WorkflowBlock({
{hasContentBelowHeader && (
<div className='flex flex-col gap-[8px] p-[8px]'>
{type === 'condition'
? conditionRows.map((cond) => (
{type === 'condition' ? (
conditionRows.map((cond) => (
<SubBlockRow key={cond.id} title={cond.title} value={getDisplayValue(cond.value)} />
))
) : type === 'router_v2' ? (
<>
<SubBlockRow
key='context'
title='Context'
value={getDisplayValue(subBlockState.context?.value)}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={cond.id}
title={cond.title}
value={getDisplayValue(cond.value)}
key={route.id}
title={`Route ${index + 1}`}
value={getDisplayValue(route.value)}
/>
))
: subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)}
))}
</>
) : (
subBlockRows.map((row, rowIndex) =>
row.map((subBlock) => {
const rawValue = subBlockState[subBlock.id]?.value
return (
<SubBlockRow
key={`${subBlock.id}-${rowIndex}`}
title={subBlock.title ?? subBlock.id}
value={getDisplayValue(rawValue)}
subBlock={subBlock}
rawValue={rawValue}
workspaceId={workspaceId}
workflowId={currentWorkflowId}
blockId={id}
allSubBlockValues={subBlockState}
/>
)
})
)
)}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
</div>
)}
@@ -1153,7 +1201,58 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</>
)}
{type !== 'condition' && type !== 'response' && (
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
// +1 row offset for context row at the top
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={getHandleClasses('right')}
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
data-nodeid={id}
data-handleid={`router-${route.id}`}
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
const edges = useWorkflowStore.getState().edges
return !wouldCreateCycle(edges, connection.source!, connection.target!)
}}
/>
</>
)}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'

View File

@@ -321,7 +321,7 @@ describe('Blocks Module', () => {
it('should have correct metadata', () => {
expect(block?.type).toBe('router')
expect(block?.name).toBe('Router')
expect(block?.name).toBe('Router (Legacy)')
expect(block?.category).toBe('blocks')
expect(block?.authMode).toBe(AuthMode.ApiKey)
})
@@ -454,6 +454,7 @@ describe('Blocks Module', () => {
'workflow-selector',
'workflow-input-mapper',
'text',
'router-input',
]
const blocks = getAllBlocks()

View File

@@ -51,6 +51,9 @@ interface TargetBlock {
currentState?: any
}
/**
* Generates the system prompt for the legacy router (block-based).
*/
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => {
const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request.
@@ -107,9 +110,88 @@ Example: "2acd9007-27e8-4510-a487-73d3b825e7c1"
Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.`
}
/**
* Generates the system prompt for the port-based router (v2).
* Instead of selecting a block by ID, it selects a route by evaluating all route descriptions.
*/
export const generateRouterV2Prompt = (
context: string,
routes: Array<{ id: string; title: string; value: string }>
): string => {
const routesInfo = routes
.map(
(route, index) => `
Route ${index + 1}:
ID: ${route.id}
Description: ${route.value || 'No description provided'}
---`
)
.join('\n')
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
Available Routes:
${routesInfo}
Context to analyze:
${context}
Instructions:
1. Carefully analyze the context against each route's description
2. Select the route that best matches the context's intent and requirements
3. Consider the semantic meaning, not just keyword matching
4. If multiple routes could match, choose the most specific one
Response Format:
Return ONLY the route ID as a single string, no punctuation, no explanation.
Example: "route-abc123"
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
}
/**
* Helper to get model options for both router versions.
*/
const getModelOptions = () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
}
/**
* Helper to get API key condition for both router versions.
*/
const getApiKeyCondition = () => {
return isHosted
? {
field: 'model',
value: [...getHostedModels(), ...providers.vertex.models],
not: true,
}
: () => ({
field: 'model',
value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
not: true,
})
}
/**
* Legacy Router Block (block-based routing).
* Hidden from toolbar but still supported for existing workflows.
*/
export const RouterBlock: BlockConfig<RouterResponse> = {
type: 'router',
name: 'Router',
name: 'Router (Legacy)',
description: 'Route workflow',
authMode: AuthMode.ApiKey,
longDescription:
@@ -121,6 +203,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
category: 'blocks',
bgColor: '#28C43F',
icon: ConnectIcon,
hideFromToolbar: true, // Hide legacy version from toolbar
subBlocks: [
{
id: 'prompt',
@@ -136,21 +219,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: () => {
const providersState = useProvidersStore.getState()
const baseModels = providersState.providers.base.models
const ollamaModels = providersState.providers.ollama.models
const vllmModels = providersState.providers.vllm.models
const openrouterModels = providersState.providers.openrouter.models
const allModels = Array.from(
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
)
return allModels.map((model) => {
const icon = getProviderIcon(model)
return { label: model, id: model, ...(icon && { icon }) }
})
},
options: getModelOptions,
},
{
id: 'vertexCredential',
@@ -173,22 +242,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
password: true,
connectionDroppable: false,
required: true,
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
condition: isHosted
? {
field: 'model',
value: [...getHostedModels(), ...providers.vertex.models],
not: true, // Show for all models EXCEPT those listed
}
: () => ({
field: 'model',
value: [
...getCurrentOllamaModels(),
...getCurrentVLLMModels(),
...providers.vertex.models,
],
not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
}),
condition: getApiKeyCondition(),
},
{
id: 'azureEndpoint',
@@ -303,3 +357,185 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
selectedPath: { type: 'json', description: 'Selected routing path' },
},
}
/**
* Router V2 Block (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
interface RouterV2Response extends ToolResponse {
output: {
context: string
model: string
tokens?: {
prompt?: number
completion?: number
total?: number
}
cost?: {
input: number
output: number
total: number
}
selectedRoute: string
selectedPath: {
blockId: string
blockType: string
blockTitle: string
}
}
}
export const RouterV2Block: BlockConfig<RouterV2Response> = {
type: 'router_v2',
name: 'Router',
description: 'Route workflow based on context',
authMode: AuthMode.ApiKey,
longDescription:
'Intelligently route workflow execution to different paths based on context analysis. Define multiple routes with descriptions, and an LLM will determine which route to take based on the provided context.',
bestPractices: `
- Write clear, specific descriptions for each route
- The context field should contain all relevant information for routing decisions
- Route descriptions should be mutually exclusive when possible
- Use descriptive route names to make the workflow readable
`,
category: 'blocks',
bgColor: '#28C43F',
icon: ConnectIcon,
subBlocks: [
{
id: 'context',
title: 'Context',
type: 'long-input',
placeholder: 'Enter the context to analyze for routing...',
required: true,
},
{
id: 'routes',
type: 'router-input',
},
{
id: 'model',
title: 'Model',
type: 'combobox',
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
options: getModelOptions,
},
{
id: 'vertexCredential',
title: 'Google Cloud Account',
type: 'oauth-input',
serviceId: 'vertex-ai',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account',
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
{
id: 'azureEndpoint',
title: 'Azure OpenAI Endpoint',
type: 'short-input',
password: true,
placeholder: 'https://your-resource.openai.azure.com',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'azureApiVersion',
title: 'Azure API Version',
type: 'short-input',
placeholder: '2024-07-01-preview',
connectionDroppable: false,
condition: {
field: 'model',
value: providers['azure-openai'].models,
},
},
{
id: 'vertexProject',
title: 'Vertex AI Project',
type: 'short-input',
placeholder: 'your-gcp-project-id',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
{
id: 'vertexLocation',
title: 'Vertex AI Location',
type: 'short-input',
placeholder: 'us-central1',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: providers.vertex.models,
},
},
],
tools: {
access: [
'openai_chat',
'anthropic_chat',
'google_chat',
'xai_chat',
'deepseek_chat',
'deepseek_reasoner',
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o'
if (!model) {
throw new Error('No model selected')
}
const tool = getAllModelProviders()[model as ProviderId]
if (!tool) {
throw new Error(`Invalid model selected: ${model}`)
}
return tool
},
},
},
inputs: {
context: { type: 'string', description: 'Context for routing decision' },
routes: { type: 'json', description: 'Route definitions with descriptions' },
model: { type: 'string', description: 'AI model to use' },
apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
vertexCredential: {
type: 'string',
description: 'Google Cloud OAuth credential ID for Vertex AI',
},
},
outputs: {
context: { type: 'string', description: 'Context used for routing' },
model: { type: 'string', description: 'Model used' },
tokens: { type: 'json', description: 'Token usage' },
cost: { type: 'json', description: 'Cost information' },
selectedRoute: { type: 'string', description: 'Selected route ID' },
selectedPath: { type: 'json', description: 'Selected routing path' },
},
}

View File

@@ -92,7 +92,7 @@ import { RDSBlock } from '@/blocks/blocks/rds'
import { RedditBlock } from '@/blocks/blocks/reddit'
import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response'
import { RouterBlock } from '@/blocks/blocks/router'
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
import { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
@@ -244,6 +244,7 @@ export const registry: Record<string, BlockConfig> = {
response: ResponseBlock,
rss: RssBlock,
router: RouterBlock,
router_v2: RouterV2Block,
s3: S3Block,
salesforce: SalesforceBlock,
schedule: ScheduleBlock,

View File

@@ -78,6 +78,7 @@ export type SubBlockType =
| 'workflow-selector' // Workflow selector for agent tools
| 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow
| 'text' // Read-only text display
| 'router-input' // Router route definitions with descriptions
/**
* Selector types that require display name hydration
@@ -288,11 +289,19 @@ export interface SubBlockConfig {
useWebhookUrl?: boolean
// Trigger-save specific: The trigger ID for validation and saving
triggerId?: string
// Dropdown specific: Function to fetch options dynamically (for multi-select or single-select)
// Dropdown/Combobox: Function to fetch options dynamically
// Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support)
fetchOptions?: (
blockId: string,
subBlockId: string
) => Promise<Array<{ label: string; id: string }>>
// Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration)
// Called when component mounts with a stored value to display the correct label before options load
fetchOptionById?: (
blockId: string,
subBlockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
}
export interface BlockConfig<T extends ToolResponse = ToolResponse> {

View File

@@ -2,6 +2,7 @@ export enum BlockType {
PARALLEL = 'parallel',
LOOP = 'loop',
ROUTER = 'router',
ROUTER_V2 = 'router_v2',
CONDITION = 'condition',
START_TRIGGER = 'start_trigger',
@@ -271,7 +272,11 @@ export function isConditionBlockType(blockType: string | undefined): boolean {
}
export function isRouterBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER
return blockType === BlockType.ROUTER || blockType === BlockType.ROUTER_V2
}
export function isRouterV2BlockType(blockType: string | undefined): boolean {
return blockType === BlockType.ROUTER_V2
}
export function isAgentBlockType(blockType: string | undefined): boolean {

View File

@@ -1,5 +1,10 @@
import { createLogger } from '@sim/logger'
import { EDGE, isConditionBlockType, isRouterBlockType } from '@/executor/constants'
import {
EDGE,
isConditionBlockType,
isRouterBlockType,
isRouterV2BlockType,
} from '@/executor/constants'
import type { DAG } from '@/executor/dag/builder'
import {
buildBranchNodeId,
@@ -19,10 +24,17 @@ interface ConditionConfig {
condition: string
}
interface RouterV2RouteConfig {
id: string
title: string
description: string
}
interface EdgeMetadata {
blockTypeMap: Map<string, string>
conditionConfigMap: Map<string, ConditionConfig[]>
routerBlockIds: Set<string>
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
}
export class EdgeConstructor {
@@ -58,6 +70,7 @@ export class EdgeConstructor {
const blockTypeMap = new Map<string, string>()
const conditionConfigMap = new Map<string, ConditionConfig[]>()
const routerBlockIds = new Set<string>()
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
for (const block of workflow.blocks) {
const blockType = block.metadata?.id ?? ''
@@ -69,12 +82,19 @@ export class EdgeConstructor {
if (conditions) {
conditionConfigMap.set(block.id, conditions)
}
} else if (isRouterV2BlockType(blockType)) {
// Router V2 uses port-based routing with route configs
const routes = this.parseRouterV2Config(block)
if (routes) {
routerV2ConfigMap.set(block.id, routes)
}
} else if (isRouterBlockType(blockType)) {
// Legacy router uses target block IDs
routerBlockIds.add(block.id)
}
}
return { blockTypeMap, conditionConfigMap, routerBlockIds }
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
}
private parseConditionConfig(block: any): ConditionConfig[] | null {
@@ -100,6 +120,29 @@ export class EdgeConstructor {
}
}
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
try {
const routesJson = block.config.params?.routes
if (typeof routesJson === 'string') {
return JSON.parse(routesJson)
}
if (Array.isArray(routesJson)) {
return routesJson
}
return null
} catch (error) {
logger.warn('Failed to parse router v2 config', {
blockId: block.id,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
private generateSourceHandle(
source: string,
target: string,
@@ -123,6 +166,26 @@ export class EdgeConstructor {
}
}
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
// We don't modify it here, just validate it exists
if (metadata.routerV2ConfigMap.has(source)) {
// For router_v2, the sourceHandle should already be set from the UI
// If not set and not an error handle, generate based on route index
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
const routes = metadata.routerV2ConfigMap.get(source)
if (routes && routes.length > 0) {
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
if (edgeIndex >= 0 && edgeIndex < routes.length) {
const correspondingRoute = routes[edgeIndex]
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
}
}
}
}
// Legacy router uses target block ID
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
handle = `${EDGE.ROUTER_PREFIX}${target}`
}

View File

@@ -4,29 +4,60 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants'
import {
BlockType,
DEFAULTS,
HTTP,
isAgentBlockType,
isRouterV2BlockType,
ROUTER,
} from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/**
* Handler for Router blocks that dynamically select execution paths.
* Supports both legacy router (block-based) and router_v2 (port-based).
*/
export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const isV2 = isRouterV2BlockType(block.metadata?.id)
if (isV2) {
return this.executeV2(ctx, block, inputs)
}
return this.executeLegacy(ctx, block, inputs)
}
/**
* Execute legacy router (block-based routing).
*/
private async executeLegacy(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block)
@@ -144,6 +175,168 @@ export class RouterBlockHandler implements BlockHandler {
}
}
/**
* Execute router v2 (port-based routing).
* Uses route definitions with descriptions instead of downstream block names.
*/
private async executeV2(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<BlockOutput> {
const routes = this.parseRoutes(inputs.routes)
if (routes.length === 0) {
throw new Error('No routes defined for router')
}
const routerConfig = {
context: inputs.context,
model: inputs.model || ROUTER.DEFAULT_MODEL,
apiKey: inputs.apiKey,
vertexProject: inputs.vertexProject,
vertexLocation: inputs.vertexLocation,
vertexCredential: inputs.vertexCredential,
}
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getBaseUrl())
const messages = [{ role: 'user', content: routerConfig.context }]
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
let finalApiKey: string | undefined = routerConfig.apiKey
if (providerId === 'vertex' && routerConfig.vertexCredential) {
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
}
const providerRequest: Record<string, any> = {
provider: providerId,
model: routerConfig.model,
systemPrompt: systemPrompt,
context: JSON.stringify(messages),
temperature: ROUTER.INFERENCE_TEMPERATURE,
apiKey: finalApiKey,
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
}
if (providerId === 'vertex') {
providerRequest.vertexProject = routerConfig.vertexProject
providerRequest.vertexLocation = routerConfig.vertexLocation
}
if (providerId === 'azure-openai') {
providerRequest.azureEndpoint = inputs.azureEndpoint
providerRequest.azureApiVersion = inputs.azureApiVersion
}
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': HTTP.CONTENT_TYPE.JSON,
},
body: JSON.stringify(providerRequest),
})
if (!response.ok) {
let errorMessage = `Provider API request failed with status ${response.status}`
try {
const errorData = await response.json()
if (errorData.error) {
errorMessage = errorData.error
}
} catch (_e) {}
throw new Error(errorMessage)
}
const result = await response.json()
const chosenRouteId = result.content.trim()
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
if (!chosenRoute) {
logger.error(
`Invalid routing decision. Response content: "${result.content}", available routes:`,
routes.map((r) => ({ id: r.id, title: r.title }))
)
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
}
// Find the target block connected to this route's handle
const connection = ctx.workflow?.connections.find(
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
)
const targetBlock = connection
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
: null
const tokens = result.tokens || {
input: DEFAULTS.TOKENS.PROMPT,
output: DEFAULTS.TOKENS.COMPLETION,
total: DEFAULTS.TOKENS.TOTAL,
}
const cost = calculateCost(
result.model,
tokens.input || DEFAULTS.TOKENS.PROMPT,
tokens.output || DEFAULTS.TOKENS.COMPLETION,
false
)
return {
context: inputs.context,
model: result.model,
tokens: {
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
},
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
},
selectedRoute: chosenRoute.id,
selectedPath: targetBlock
? {
blockId: targetBlock.id,
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
}
: {
blockId: '',
blockType: DEFAULTS.BLOCK_TYPE,
blockTitle: chosenRoute.title,
},
} as BlockOutput
} catch (error) {
logger.error('Router V2 execution failed:', error)
throw error
}
}
/**
* Parse routes from input (can be JSON string or array).
*/
private parseRoutes(input: any): RouteDefinition[] {
try {
if (typeof input === 'string') {
return JSON.parse(input)
}
if (Array.isArray(input)) {
return input
}
return []
} catch (error) {
logger.error('Failed to parse routes:', { input, error })
return []
}
}
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections
.filter((conn) => conn.source === block.id)

View File

@@ -70,7 +70,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
return allHeaders
},
body: (params: RequestParams) => {
body: ((params: RequestParams) => {
if (params.formData) {
const formData = new FormData()
Object.entries(params.formData).forEach(([key, value]) => {
@@ -90,7 +90,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
) {
// Convert JSON object to URL-encoded string
const urlencoded = new URLSearchParams()
Object.entries(params.body).forEach(([key, value]) => {
Object.entries(params.body as Record<string, unknown>).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlencoded.append(key, String(value))
}
@@ -98,11 +98,11 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
return urlencoded.toString()
}
return params.body
return params.body as Record<string, any>
}
return undefined
},
}) as (params: RequestParams) => Record<string, any> | string | FormData | undefined,
},
transformResponse: async (response: Response) => {

View File

@@ -68,7 +68,7 @@ export const webhookRequestTool: ToolConfig<WebhookRequestParams, RequestRespons
return { ...webhookHeaders, ...userHeaders }
},
body: (params: WebhookRequestParams) => params.body,
body: (params: WebhookRequestParams) => params.body as Record<string, any>,
},
transformResponse: async (response: Response) => {

View File

@@ -93,7 +93,7 @@ export interface ToolConfig<P = any, R = any> {
url: string | ((params: P) => string)
method: HttpMethod | ((params: P) => HttpMethod)
headers: (params: P) => Record<string, string>
body?: (params: P) => Record<string, any> | string
body?: (params: P) => Record<string, any> | string | FormData | undefined
}
// Post-processing (optional) - allows additional processing after the initial request