mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
v0.5.52: new port-based router block, combobox expression and variable support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user