Compare commits

...

4 Commits

Author SHA1 Message Date
Siddharth Ganesan
97839473c5 Superagent 2026-01-05 15:48:08 -08:00
Siddharth Ganesan
c369e04189 Fix lint 2026-01-05 12:22:05 -08:00
Siddharth Ganesan
27c7bcf71d Add tag dropdowns 2026-01-05 12:20:40 -08:00
Siddharth Ganesan
0bc8c0e1bc Add ports to router block 2026-01-05 11:34:22 -08:00
21 changed files with 958 additions and 139 deletions

View File

@@ -0,0 +1,42 @@
'use client'
import { useEffect } from 'react'
import { Tooltip } from '@/components/emcn'
import { Copilot } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
* Superagent page - standalone AI agent with full credential access
* Uses the exact same Copilot UI but with superagent mode forced
*/
export default function AgentPage() {
const { setMode } = useCopilotStore()
// Set superagent mode on mount
useEffect(() => {
setMode('superagent')
}, [setMode])
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen flex-col bg-[var(--surface-1)]'>
{/* Header */}
<header className='flex h-14 flex-shrink-0 items-center justify-between border-b border-[var(--border)] px-4'>
<div className='flex items-center gap-3'>
<h1 className='font-semibold text-lg text-[var(--text-primary)]'>Superagent</h1>
<span className='rounded-full bg-[var(--accent)]/10 px-2 py-0.5 font-medium text-[var(--accent)] text-xs'>
Full Access
</span>
</div>
</header>
{/* Copilot - exact same component in standalone mode */}
<div className='flex-1 overflow-hidden p-4'>
<div className='mx-auto h-full max-w-4xl'>
<Copilot panelWidth={800} standalone />
</div>
</div>
</div>
</Tooltip.Provider>
)
}

View File

@@ -38,7 +38,7 @@ const ChatMessageSchema = z.object({
message: z.string().min(1, 'Message is required'), message: z.string().min(1, 'Message is required'),
userMessageId: z.string().optional(), // ID from frontend for the user message userMessageId: z.string().optional(), // ID from frontend for the user message
chatId: z.string().optional(), chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'), workflowId: z.string().optional(),
model: z model: z
.enum([ .enum([
'gpt-5-fast', 'gpt-5-fast',
@@ -63,7 +63,7 @@ const ChatMessageSchema = z.object({
]) ])
.optional() .optional()
.default('claude-4.5-opus'), .default('claude-4.5-opus'),
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'), mode: z.enum(['ask', 'agent', 'plan', 'superagent']).optional().default('agent'),
prefetch: z.boolean().optional(), prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false), createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true), stream: z.boolean().optional().default(true),
@@ -339,7 +339,7 @@ export async function POST(req: NextRequest) {
} }
} | null = null } | null = null
if (mode === 'agent') { if (mode === 'agent' || mode === 'superagent') {
// Build base tools (executed locally, not deferred) // Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability // Include function_execute for code execution capability
baseTools = [ baseTools = [

View File

@@ -25,7 +25,7 @@ const ExecuteToolSchema = z.object({
toolCallId: z.string(), toolCallId: z.string(),
toolName: z.string(), toolName: z.string(),
arguments: z.record(z.any()).optional().default({}), arguments: z.record(z.any()).optional().default({}),
workflowId: z.string().optional(), workflowId: z.string().nullish(), // Accept undefined or null for superagent mode
}) })
/** /**

View File

@@ -276,7 +276,7 @@ function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean {
const mode = useCopilotStore.getState().mode const mode = useCopilotStore.getState().mode
const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name) const isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
if ( if (
mode === 'build' && (mode === 'build' || mode === 'superagent') &&
isIntegrationTool(toolCall.name) && isIntegrationTool(toolCall.name) &&
toolCall.state === 'pending' && toolCall.state === 'pending' &&
!isAutoAllowed !isAutoAllowed
@@ -564,11 +564,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
// Allow rendering if: // Allow rendering if:
// 1. Tool is in CLASS_TOOL_METADATA (client tools), OR // 1. Tool is in CLASS_TOOL_METADATA (client tools), OR
// 2. We're in build mode (integration tools are executed server-side) // 2. We're in build or superagent mode (integration tools are executed server-side)
const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name] const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name]
const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool const isIntegrationToolInAgentMode = (mode === 'build' || mode === 'superagent') && !isClientTool
if (!isClientTool && !isIntegrationToolInBuildMode) { if (!isClientTool && !isIntegrationToolInAgentMode) {
return null return null
} }
const isExpandableTool = const isExpandableTool =

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ListTree, MessageSquare, Package } from 'lucide-react' import { ListTree, MessageSquare, Package, Zap } from 'lucide-react'
import { import {
Badge, Badge,
Popover, Popover,
@@ -13,10 +13,10 @@ import {
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
interface ModeSelectorProps { interface ModeSelectorProps {
/** Current mode - 'ask', 'build', or 'plan' */ /** Current mode - 'ask', 'build', 'plan', or 'superagent' */
mode: 'ask' | 'build' | 'plan' mode: 'ask' | 'build' | 'plan' | 'superagent'
/** Callback when mode changes */ /** Callback when mode changes */
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
/** Whether the input is near the top of viewport (affects dropdown direction) */ /** Whether the input is near the top of viewport (affects dropdown direction) */
isNearTop: boolean isNearTop: boolean
/** Whether the selector is disabled */ /** Whether the selector is disabled */
@@ -42,6 +42,9 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
if (mode === 'plan') { if (mode === 'plan') {
return <ListTree className='h-3 w-3' /> return <ListTree className='h-3 w-3' />
} }
if (mode === 'superagent') {
return <Zap className='h-3 w-3' />
}
return <Package className='h-3 w-3' /> return <Package className='h-3 w-3' />
} }
@@ -52,10 +55,13 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe
if (mode === 'plan') { if (mode === 'plan') {
return 'Plan' return 'Plan'
} }
if (mode === 'superagent') {
return 'Superagent'
}
return 'Build' return 'Build'
} }
const handleSelect = (selectedMode: 'ask' | 'build' | 'plan') => { const handleSelect = (selectedMode: 'ask' | 'build' | 'plan' | 'superagent') => {
onModeChange?.(selectedMode) onModeChange?.(selectedMode)
setOpen(false) setOpen(false)
} }

View File

@@ -51,8 +51,8 @@ interface UserInputProps {
isAborting?: boolean isAborting?: boolean
placeholder?: string placeholder?: string
className?: string className?: string
mode?: 'ask' | 'build' | 'plan' mode?: 'ask' | 'build' | 'plan' | 'superagent'
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void onModeChange?: (mode: 'ask' | 'build' | 'plan' | 'superagent') => void
value?: string value?: string
onChange?: (value: string) => void onChange?: (value: string) => void
panelWidth?: number panelWidth?: number

View File

@@ -8,8 +8,8 @@ import { Button } from '@/components/emcn'
interface WelcomeProps { interface WelcomeProps {
/** Callback when a suggested question is clicked */ /** Callback when a suggested question is clicked */
onQuestionClick?: (question: string) => void onQuestionClick?: (question: string) => void
/** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building) */ /** Current copilot mode ('ask' for Q&A, 'plan' for planning, 'build' for workflow building, 'superagent' for full access) */
mode?: 'ask' | 'build' | 'plan' mode?: 'ask' | 'build' | 'plan' | 'superagent'
} }
/** /**

View File

@@ -49,6 +49,8 @@ const logger = createLogger('Copilot')
interface CopilotProps { interface CopilotProps {
/** Width of the copilot panel in pixels */ /** Width of the copilot panel in pixels */
panelWidth: number panelWidth: number
/** If true, runs in standalone mode without workflow context (for superagent) */
standalone?: boolean
} }
/** /**
@@ -67,7 +69,7 @@ interface CopilotRef {
* Copilot component - AI-powered assistant for workflow management * Copilot component - AI-powered assistant for workflow management
* Provides chat interface, message history, and intelligent workflow suggestions * Provides chat interface, message history, and intelligent workflow suggestions
*/ */
export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref) => { export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth, standalone = false }, ref) => {
const userInputRef = useRef<UserInputRef>(null) const userInputRef = useRef<UserInputRef>(null)
const copilotContainerRef = useRef<HTMLDivElement>(null) const copilotContainerRef = useRef<HTMLDivElement>(null)
const cancelEditCallbackRef = useRef<(() => void) | null>(null) const cancelEditCallbackRef = useRef<(() => void) | null>(null)
@@ -122,6 +124,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
loadAutoAllowedTools, loadAutoAllowedTools,
currentChat, currentChat,
isSendingMessage, isSendingMessage,
standalone,
}) })
// Handle scroll management // Handle scroll management
@@ -298,7 +301,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
*/ */
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => { async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return if (!query || isSendingMessage || (!activeWorkflowId && !standalone)) return
if (showPlanTodos) { if (showPlanTodos) {
const store = useCopilotStore.getState() const store = useCopilotStore.getState()
@@ -316,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error) logger.error('Failed to send message:', error)
} }
}, },
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos] [isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos, standalone]
) )
/** /**
@@ -487,11 +490,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
ref={userInputRef} ref={userInputRef}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onAbort={handleAbort} onAbort={handleAbort}
disabled={!activeWorkflowId} disabled={!activeWorkflowId && !standalone}
isLoading={isSendingMessage} isLoading={isSendingMessage}
isAborting={isAborting} isAborting={isAborting}
mode={mode} mode={mode}
onModeChange={setMode} onModeChange={standalone ? undefined : setMode}
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
panelWidth={panelWidth} panelWidth={panelWidth}
@@ -594,11 +597,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
ref={userInputRef} ref={userInputRef}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onAbort={handleAbort} onAbort={handleAbort}
disabled={!activeWorkflowId} disabled={!activeWorkflowId && !standalone}
isLoading={isSendingMessage} isLoading={isSendingMessage}
isAborting={isAborting} isAborting={isAborting}
mode={mode} mode={mode}
onModeChange={setMode} onModeChange={standalone ? undefined : setMode}
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
panelWidth={panelWidth} panelWidth={panelWidth}

View File

@@ -15,6 +15,8 @@ interface UseCopilotInitializationProps {
loadAutoAllowedTools: () => Promise<void> loadAutoAllowedTools: () => Promise<void>
currentChat: any currentChat: any
isSendingMessage: boolean isSendingMessage: boolean
/** If true, initializes without requiring a workflowId (for standalone agent mode) */
standalone?: boolean
} }
/** /**
@@ -34,6 +36,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
loadAutoAllowedTools, loadAutoAllowedTools,
currentChat, currentChat,
isSendingMessage, isSendingMessage,
standalone = false,
} = props } = props
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
@@ -46,6 +49,14 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
* Never loads during message streaming to prevent interrupting active conversations * Never loads during message streaming to prevent interrupting active conversations
*/ */
useEffect(() => { useEffect(() => {
// Standalone mode: initialize immediately without workflow
if (standalone && !hasMountedRef.current && !isSendingMessage) {
hasMountedRef.current = true
setIsInitialized(true)
logger.info('Standalone mode initialized')
return
}
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) { if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
hasMountedRef.current = true hasMountedRef.current = true
setIsInitialized(false) setIsInitialized(false)
@@ -55,7 +66,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
// Use false to let the store decide if a reload is needed based on cache // Use false to let the store decide if a reload is needed based on cache
loadChats(false) loadChats(false)
} }
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage]) }, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage, standalone])
/** /**
* Initialize the component - only on mount and genuine workflow changes * Initialize the component - only on mount and genuine workflow changes
@@ -63,6 +74,9 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
* Never reloads during message streaming to preserve active conversations * Never reloads during message streaming to preserve active conversations
*/ */
useEffect(() => { useEffect(() => {
// Skip workflow tracking in standalone mode
if (standalone) return
// Handle genuine workflow changes (not initial mount, not same workflow) // Handle genuine workflow changes (not initial mount, not same workflow)
// Only reload if not currently streaming to avoid interrupting conversations // Only reload if not currently streaming to avoid interrupting conversations
if ( if (
@@ -100,19 +114,23 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
setCopilotWorkflowId, setCopilotWorkflowId,
loadChats, loadChats,
isSendingMessage, isSendingMessage,
standalone,
]) ])
/** /**
* Fetch context usage when component is initialized and has a current chat * Fetch context usage when component is initialized and has a current chat
*/ */
useEffect(() => { useEffect(() => {
// In standalone mode, skip context usage fetch (no workflow context)
if (standalone) return
if (isInitialized && currentChat?.id && activeWorkflowId) { if (isInitialized && currentChat?.id && activeWorkflowId) {
logger.info('[Copilot] Component initialized, fetching context usage') logger.info('[Copilot] Component initialized, fetching context usage')
fetchContextUsage().catch((err) => { fetchContextUsage().catch((err) => {
logger.warn('[Copilot] Failed to fetch context usage on mount', err) logger.warn('[Copilot] Failed to fetch context usage on mount', err)
}) })
} }
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage]) }, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage, standalone])
/** /**
* Load auto-allowed tools once on mount * Load auto-allowed tools once on mount

View File

@@ -12,6 +12,7 @@ import {
getCodeEditorProps, getCodeEditorProps,
highlight, highlight,
languages, languages,
Textarea,
Tooltip, Tooltip,
} from '@/components/emcn' } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash' import { Trash } from '@/components/emcn/icons/trash'
@@ -74,6 +75,8 @@ interface ConditionInputProps {
previewValue?: string | null previewValue?: string | null
/** Whether the component is disabled */ /** Whether the component is disabled */
disabled?: boolean disabled?: boolean
/** Mode: 'condition' for code editor, 'router' for text input */
mode?: 'condition' | 'router'
} }
/** /**
@@ -101,7 +104,9 @@ export function ConditionInput({
isPreview = false, isPreview = false,
previewValue, previewValue,
disabled = false, disabled = false,
mode = 'condition',
}: ConditionInputProps) { }: ConditionInputProps) {
const isRouterMode = mode === 'router'
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
@@ -161,32 +166,50 @@ export function ConditionInput({
const shouldPersistRef = useRef<boolean>(false) 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[] => [ const createDefaultBlocks = (): ConditionalBlock[] => {
{ if (isRouterMode) {
id: generateStableId(blockId, 'if'), return [
title: 'if', {
value: '', id: generateStableId(blockId, 'route1'),
showTags: false, title: 'route1',
showEnvVars: false, value: '',
searchTerm: '', showTags: false,
cursorPosition: 0, showEnvVars: false,
activeSourceBlockId: null, searchTerm: '',
}, cursorPosition: 0,
{ activeSourceBlockId: null,
id: generateStableId(blockId, 'else'), },
title: 'else', ]
value: '', }
showTags: false,
showEnvVars: false, return [
searchTerm: '', {
cursorPosition: 0, id: generateStableId(blockId, 'if'),
activeSourceBlockId: null, 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 // Initialize with a loading state instead of default blocks
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([]) const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([])
@@ -270,10 +293,13 @@ export function ConditionInput({
const parsedBlocks = safeParseJSON(effectiveValueStr) const parsedBlocks = safeParseJSON(effectiveValueStr)
if (parsedBlocks) { if (parsedBlocks) {
const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({ // For router mode, keep original titles. For condition mode, assign if/else if/else
...block, const blocksWithCorrectTitles = isRouterMode
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if', ? parsedBlocks
})) : parsedBlocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if',
}))
setConditionalBlocks(blocksWithCorrectTitles) setConditionalBlocks(blocksWithCorrectTitles)
hasInitializedRef.current = true hasInitializedRef.current = true
@@ -573,12 +599,17 @@ export function ConditionInput({
/** /**
* Updates block titles based on their position in the array. * 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 * @param blocks - Array of conditional blocks
* @returns Updated blocks with correct titles * @returns Updated blocks with correct titles
*/ */
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => { 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) => ({ return blocks.map((block, index) => ({
...block, ...block,
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if', title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
@@ -590,13 +621,15 @@ export function ConditionInput({
if (isPreview || disabled) return if (isPreview || disabled) return
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId) 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 = { const newBlock: ConditionalBlock = {
id: newBlockId, id: newBlockId,
title: '', title: isRouterMode ? `route-${Date.now()}` : '',
value: '', value: '',
showTags: false, showTags: false,
showEnvVars: false, showEnvVars: false,
@@ -710,13 +743,15 @@ export function ConditionInput({
<div <div
className={cn( className={cn(
'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]', 'flex items-center justify-between overflow-hidden bg-transparent px-[10px] py-[5px]',
block.title === 'else' isRouterMode
? 'rounded-[4px] border-0' ? 'rounded-t-[4px] border-[var(--border-1)] border-b'
: '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)]'> <span className='font-medium text-[14px] text-[var(--text-tertiary)]'>
{block.title} {isRouterMode ? `Route ${index + 1}` : block.title}
</span> </span>
<div className='flex items-center gap-[8px]'> <div className='flex items-center gap-[8px]'>
<Tooltip.Root> <Tooltip.Root>
@@ -724,7 +759,7 @@ export function ConditionInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={() => addBlock(block.id)} onClick={() => addBlock(block.id)}
disabled={isPreview || disabled || block.title === 'else'} disabled={isPreview || disabled || (!isRouterMode && block.title === 'else')}
className='h-auto p-0' className='h-auto p-0'
> >
<Plus className='h-[14px] w-[14px]' /> <Plus className='h-[14px] w-[14px]' />
@@ -739,7 +774,12 @@ export function ConditionInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={() => moveBlock(block.id, 'up')} 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' className='h-auto p-0'
> >
<ChevronUp className='h-[14px] w-[14px]' /> <ChevronUp className='h-[14px] w-[14px]' />
@@ -758,8 +798,8 @@ export function ConditionInput({
isPreview || isPreview ||
disabled || disabled ||
index === conditionalBlocks.length - 1 || index === conditionalBlocks.length - 1 ||
conditionalBlocks[index + 1]?.title === 'else' || (!isRouterMode && conditionalBlocks[index + 1]?.title === 'else') ||
block.title === 'else' (!isRouterMode && block.title === 'else')
} }
className='h-auto p-0' className='h-auto p-0'
> >
@@ -775,18 +815,122 @@ export function ConditionInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={() => removeBlock(block.id)} 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)]' className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
> >
<Trash className='h-[14px] w-[14px]' /> <Trash className='h-[14px] w-[14px]' />
<span className='sr-only'>Delete Block</span> <span className='sr-only'>Delete Block</span>
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content>Delete Condition</Tooltip.Content> <Tooltip.Content>
{isRouterMode ? 'Delete Route' : 'Delete Condition'}
</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</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 blockLineCount = block.value.split('\n').length
const blockGutterWidth = calculateGutterWidth(blockLineCount) const blockGutterWidth = calculateGutterWidth(blockLineCount)

View File

@@ -605,6 +605,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': case 'eval-input':
return ( return (
<EvalInput <EvalInput

View File

@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
] ]
}, [type, subBlockState, id]) }, [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. * Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump". * This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -857,6 +888,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
let rowsCount = 0 let rowsCount = 0
if (type === 'condition') { if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
rowsCount = routerRows.length + defaultHandlesRow
} else { } else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0) const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow rowsCount = subblockRowCount + defaultHandlesRow
@@ -879,6 +912,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayTriggerMode, displayTriggerMode,
subBlockRows.length, subBlockRows.length,
conditionRows.length, conditionRows.length,
routerRows.length,
horizontalHandles, horizontalHandles,
], ],
}) })
@@ -1081,24 +1115,32 @@ export const WorkflowBlock = memo(function WorkflowBlock({
value={getDisplayValue(cond.value)} value={getDisplayValue(cond.value)}
/> />
)) ))
: subBlockRows.map((row, rowIndex) => : type === 'router_v2'
row.map((subBlock) => { ? routerRows.map((route, index) => (
const rawValue = subBlockState[subBlock.id]?.value <SubBlockRow
return ( key={route.id}
<SubBlockRow title={`Route ${index + 1}`}
key={`${subBlock.id}-${rowIndex}`} value={getDisplayValue(route.value)}
title={subBlock.title ?? subBlock.id} />
value={getDisplayValue(rawValue)} ))
subBlock={subBlock} : subBlockRows.map((row, rowIndex) =>
rawValue={rawValue} row.map((subBlock) => {
workspaceId={workspaceId} const rawValue = subBlockState[subBlock.id]?.value
workflowId={currentWorkflowId} return (
blockId={id} <SubBlockRow
allSubBlockValues={subBlockState} 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' />} {shouldShowDefaultHandles && <SubBlockRow title='error' />}
</div> </div>
)} )}
@@ -1153,7 +1195,57 @@ export const WorkflowBlock = memo(function WorkflowBlock({
</> </>
)} )}
{type !== 'condition' && type !== 'response' && ( {type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
routeIndex * 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 <Handle
type='source' type='source'

View File

@@ -51,6 +51,9 @@ interface TargetBlock {
currentState?: any currentState?: any
} }
/**
* Generates the system prompt for the legacy router (block-based).
*/
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => { 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. 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.` 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> = { export const RouterBlock: BlockConfig<RouterResponse> = {
type: 'router', type: 'router',
name: 'Router', name: 'Router (Legacy)',
description: 'Route workflow', description: 'Route workflow',
authMode: AuthMode.ApiKey, authMode: AuthMode.ApiKey,
longDescription: longDescription:
@@ -121,6 +203,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
category: 'blocks', category: 'blocks',
bgColor: '#28C43F', bgColor: '#28C43F',
icon: ConnectIcon, icon: ConnectIcon,
hideFromToolbar: true, // Hide legacy version from toolbar
subBlocks: [ subBlocks: [
{ {
id: 'prompt', id: 'prompt',
@@ -136,21 +219,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
placeholder: 'Type or select a model...', placeholder: 'Type or select a model...',
required: true, required: true,
defaultValue: 'claude-sonnet-4-5', defaultValue: 'claude-sonnet-4-5',
options: () => { options: 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 }) }
})
},
}, },
{ {
id: 'vertexCredential', id: 'vertexCredential',
@@ -173,22 +242,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
password: true, password: true,
connectionDroppable: false, connectionDroppable: false,
required: true, required: true,
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) condition: getApiKeyCondition(),
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
}),
}, },
{ {
id: 'azureEndpoint', id: 'azureEndpoint',
@@ -303,3 +357,185 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
selectedPath: { type: 'json', description: 'Selected routing path' }, 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 { RedditBlock } from '@/blocks/blocks/reddit'
import { ResendBlock } from '@/blocks/blocks/resend' import { ResendBlock } from '@/blocks/blocks/resend'
import { ResponseBlock } from '@/blocks/blocks/response' 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 { RssBlock } from '@/blocks/blocks/rss'
import { S3Block } from '@/blocks/blocks/s3' import { S3Block } from '@/blocks/blocks/s3'
import { SalesforceBlock } from '@/blocks/blocks/salesforce' import { SalesforceBlock } from '@/blocks/blocks/salesforce'
@@ -243,6 +243,7 @@ export const registry: Record<string, BlockConfig> = {
response: ResponseBlock, response: ResponseBlock,
rss: RssBlock, rss: RssBlock,
router: RouterBlock, router: RouterBlock,
router_v2: RouterV2Block,
s3: S3Block, s3: S3Block,
salesforce: SalesforceBlock, salesforce: SalesforceBlock,
schedule: ScheduleBlock, schedule: ScheduleBlock,

View File

@@ -78,6 +78,7 @@ export type SubBlockType =
| 'workflow-selector' // Workflow selector for agent tools | 'workflow-selector' // Workflow selector for agent tools
| 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow | 'workflow-input-mapper' // Dynamic workflow input mapper based on selected workflow
| 'text' // Read-only text display | 'text' // Read-only text display
| 'router-input' // Router route definitions with descriptions
/** /**
* Selector types that require display name hydration * Selector types that require display name hydration

View File

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

View File

@@ -1,5 +1,10 @@
import { createLogger } from '@sim/logger' 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 type { DAG } from '@/executor/dag/builder'
import { import {
buildBranchNodeId, buildBranchNodeId,
@@ -19,10 +24,17 @@ interface ConditionConfig {
condition: string condition: string
} }
interface RouterV2RouteConfig {
id: string
title: string
description: string
}
interface EdgeMetadata { interface EdgeMetadata {
blockTypeMap: Map<string, string> blockTypeMap: Map<string, string>
conditionConfigMap: Map<string, ConditionConfig[]> conditionConfigMap: Map<string, ConditionConfig[]>
routerBlockIds: Set<string> routerBlockIds: Set<string>
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
} }
export class EdgeConstructor { export class EdgeConstructor {
@@ -58,6 +70,7 @@ export class EdgeConstructor {
const blockTypeMap = new Map<string, string>() const blockTypeMap = new Map<string, string>()
const conditionConfigMap = new Map<string, ConditionConfig[]>() const conditionConfigMap = new Map<string, ConditionConfig[]>()
const routerBlockIds = new Set<string>() const routerBlockIds = new Set<string>()
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
for (const block of workflow.blocks) { for (const block of workflow.blocks) {
const blockType = block.metadata?.id ?? '' const blockType = block.metadata?.id ?? ''
@@ -69,12 +82,19 @@ export class EdgeConstructor {
if (conditions) { if (conditions) {
conditionConfigMap.set(block.id, 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)) { } else if (isRouterBlockType(blockType)) {
// Legacy router uses target block IDs
routerBlockIds.add(block.id) routerBlockIds.add(block.id)
} }
} }
return { blockTypeMap, conditionConfigMap, routerBlockIds } return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
} }
private parseConditionConfig(block: any): ConditionConfig[] | null { 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( private generateSourceHandle(
source: string, source: string,
target: 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) { if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
handle = `${EDGE.ROUTER_PREFIX}${target}` handle = `${EDGE.ROUTER_PREFIX}${target}`
} }

View File

@@ -4,29 +4,60 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls' import { getBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' 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 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 type { BlockHandler, ExecutionContext } from '@/executor/types'
import { calculateCost, getProviderFromModel } from '@/providers/utils' import { calculateCost, getProviderFromModel } from '@/providers/utils'
import type { SerializedBlock } from '@/serializer/types' import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('RouterBlockHandler') const logger = createLogger('RouterBlockHandler')
interface RouteDefinition {
id: string
title: string
value: string
}
/** /**
* Handler for Router blocks that dynamically select execution paths. * 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 { export class RouterBlockHandler implements BlockHandler {
constructor(private pathTracker?: any) {} constructor(private pathTracker?: any) {}
canHandle(block: SerializedBlock): boolean { canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.ROUTER return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
} }
async execute( async execute(
ctx: ExecutionContext, ctx: ExecutionContext,
block: SerializedBlock, block: SerializedBlock,
inputs: Record<string, any> 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> { ): Promise<BlockOutput> {
const targetBlocks = this.getTargetBlocks(ctx, block) 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) { private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
return ctx.workflow?.connections return ctx.workflow?.connections
.filter((conn) => conn.source === block.id) .filter((conn) => conn.source === block.id)

View File

@@ -27,7 +27,7 @@ export interface CopilotMessage {
* Chat config stored in database * Chat config stored in database
*/ */
export interface CopilotChatConfig { export interface CopilotChatConfig {
mode?: 'ask' | 'build' | 'plan' mode?: 'ask' | 'build' | 'plan' | 'superagent'
model?: string model?: string
} }
@@ -65,7 +65,7 @@ export interface SendMessageRequest {
userMessageId?: string // ID from frontend for the user message userMessageId?: string // ID from frontend for the user message
chatId?: string chatId?: string
workflowId?: string workflowId?: string
mode?: 'ask' | 'agent' | 'plan' mode?: 'ask' | 'agent' | 'plan' | 'superagent'
model?: model?:
| 'gpt-5-fast' | 'gpt-5-fast'
| 'gpt-5' | 'gpt-5'

View File

@@ -1073,16 +1073,16 @@ const sseHandlers: Record<string, SSEHandler> = {
// Integration tools: Check if auto-allowed, otherwise wait for user confirmation // Integration tools: Check if auto-allowed, otherwise wait for user confirmation
// This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry // This handles tools like google_calendar_*, exa_*, etc. that aren't in the client registry
// Only relevant if mode is 'build' (agent) // Relevant in 'build' mode (with workflow) or 'superagent' mode (standalone)
const { mode, workflowId, autoAllowedTools } = get() const { mode, workflowId, autoAllowedTools } = get()
if (mode === 'build' && workflowId) { if ((mode === 'build' && workflowId) || mode === 'superagent') {
// Check if tool was NOT found in client registry (def is undefined from above) // Check if tool was NOT found in client registry (def is undefined from above)
const def = name ? getTool(name) : undefined const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any const inst = getClientTool(id) as any
if (!def && !inst && name) { if (!def && !inst && name) {
// Check if this tool is auto-allowed // Check if this tool is auto-allowed
if (autoAllowedTools.includes(name)) { if (autoAllowedTools.includes(name)) {
logger.info('[build mode] Integration tool auto-allowed, executing', { id, name }) logger.info('[copilot] Integration tool auto-allowed, executing', { id, name, mode })
// Auto-execute the tool // Auto-execute the tool
setTimeout(() => { setTimeout(() => {
@@ -1090,9 +1090,10 @@ const sseHandlers: Record<string, SSEHandler> = {
}, 0) }, 0)
} else { } else {
// Integration tools stay in pending state until user confirms // Integration tools stay in pending state until user confirms
logger.info('[build mode] Integration tool awaiting user confirmation', { logger.info('[copilot] Integration tool awaiting user confirmation', {
id, id,
name, name,
mode,
}) })
} }
} }
@@ -1982,7 +1983,8 @@ export const useCopilotStore = create<CopilotStore>()(
messageId?: string messageId?: string
} }
if (!workflowId) return // Allow sending without workflowId in superagent mode
if (!workflowId && mode !== 'superagent') return
const abortController = new AbortController() const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController }) set({ isSendingMessage: true, error: null, abortController })
@@ -2053,8 +2055,8 @@ export const useCopilotStore = create<CopilotStore>()(
} }
// Call copilot API // Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' = const apiMode: 'ask' | 'agent' | 'plan' | 'superagent' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent' mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : mode === 'superagent' ? 'superagent' : 'agent'
const result = await sendStreamingMessage({ const result = await sendStreamingMessage({
message: messageToSend, message: messageToSend,
userMessageId: userMessage.id, userMessageId: userMessage.id,
@@ -2916,9 +2918,10 @@ export const useCopilotStore = create<CopilotStore>()(
}, },
executeIntegrationTool: async (toolCallId: string) => { executeIntegrationTool: async (toolCallId: string) => {
const { toolCallsById, workflowId } = get() const { toolCallsById, workflowId, mode } = get()
const toolCall = toolCallsById[toolCallId] const toolCall = toolCallsById[toolCallId]
if (!toolCall || !workflowId) return // In superagent mode, workflowId is optional
if (!toolCall || (!workflowId && mode !== 'superagent')) return
const { id, name, params } = toolCall const { id, name, params } = toolCall

View File

@@ -58,7 +58,7 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
export type CopilotChat = ApiCopilotChat export type CopilotChat = ApiCopilotChat
export type CopilotMode = 'ask' | 'build' | 'plan' export type CopilotMode = 'ask' | 'build' | 'plan' | 'superagent'
export interface CopilotState { export interface CopilotState {
mode: CopilotMode mode: CopilotMode