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

View File

@@ -25,7 +25,7 @@ const ExecuteToolSchema = z.object({
toolCallId: z.string(),
toolName: z.string(),
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 isAutoAllowed = useCopilotStore.getState().isToolAutoAllowed(toolCall.name)
if (
mode === 'build' &&
(mode === 'build' || mode === 'superagent') &&
isIntegrationTool(toolCall.name) &&
toolCall.state === 'pending' &&
!isAutoAllowed
@@ -564,11 +564,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
// Allow rendering if:
// 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 isIntegrationToolInBuildMode = mode === 'build' && !isClientTool
const isIntegrationToolInAgentMode = (mode === 'build' || mode === 'superagent') && !isClientTool
if (!isClientTool && !isIntegrationToolInBuildMode) {
if (!isClientTool && !isIntegrationToolInAgentMode) {
return null
}
const isExpandableTool =

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ const logger = createLogger('Copilot')
interface CopilotProps {
/** Width of the copilot panel in pixels */
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
* 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 copilotContainerRef = useRef<HTMLDivElement>(null)
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
@@ -122,6 +124,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
loadAutoAllowedTools,
currentChat,
isSendingMessage,
standalone,
})
// Handle scroll management
@@ -298,7 +301,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return
if (!query || isSendingMessage || (!activeWorkflowId && !standalone)) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
@@ -316,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
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}
onSubmit={handleSubmit}
onAbort={handleAbort}
disabled={!activeWorkflowId}
disabled={!activeWorkflowId && !standalone}
isLoading={isSendingMessage}
isAborting={isAborting}
mode={mode}
onModeChange={setMode}
onModeChange={standalone ? undefined : setMode}
value={inputValue}
onChange={setInputValue}
panelWidth={panelWidth}
@@ -594,11 +597,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
ref={userInputRef}
onSubmit={handleSubmit}
onAbort={handleAbort}
disabled={!activeWorkflowId}
disabled={!activeWorkflowId && !standalone}
isLoading={isSendingMessage}
isAborting={isAborting}
mode={mode}
onModeChange={setMode}
onModeChange={standalone ? undefined : setMode}
value={inputValue}
onChange={setInputValue}
panelWidth={panelWidth}

View File

@@ -15,6 +15,8 @@ interface UseCopilotInitializationProps {
loadAutoAllowedTools: () => Promise<void>
currentChat: any
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,
currentChat,
isSendingMessage,
standalone = false,
} = props
const [isInitialized, setIsInitialized] = useState(false)
@@ -46,6 +49,14 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
* Never loads during message streaming to prevent interrupting active conversations
*/
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) {
hasMountedRef.current = true
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
loadChats(false)
}
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage, standalone])
/**
* 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
*/
useEffect(() => {
// Skip workflow tracking in standalone mode
if (standalone) return
// Handle genuine workflow changes (not initial mount, not same workflow)
// Only reload if not currently streaming to avoid interrupting conversations
if (
@@ -100,19 +114,23 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
setCopilotWorkflowId,
loadChats,
isSendingMessage,
standalone,
])
/**
* Fetch context usage when component is initialized and has a current chat
*/
useEffect(() => {
// In standalone mode, skip context usage fetch (no workflow context)
if (standalone) return
if (isInitialized && currentChat?.id && activeWorkflowId) {
logger.info('[Copilot] Component initialized, fetching context usage')
fetchContextUsage().catch((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

View File

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

View File

@@ -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':
return (
<EvalInput

View File

@@ -841,6 +841,37 @@ export const WorkflowBlock = memo(function WorkflowBlock({
]
}, [type, subBlockState, id])
/**
* Compute per-route rows (id/value) for router_v2 blocks so we can render
* one row per route with its own output handle.
* Uses same structure as conditions: { id, title, value }
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return [] as { id: string; value: string }[]
const routesValue = subBlockState.routes?.value
const raw = typeof routesValue === 'string' ? routesValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `${id}-route-${index}`,
value: routeItem?.value ?? '',
}
})
}
}
} catch (error) {
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
return [{ id: `${id}-route-route1`, value: '' }]
}, [type, subBlockState, id])
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
@@ -857,6 +888,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else if (type === 'router_v2') {
rowsCount = routerRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
@@ -879,6 +912,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
routerRows.length,
horizontalHandles,
],
})
@@ -1081,24 +1115,32 @@ export const WorkflowBlock = memo(function WorkflowBlock({
value={getDisplayValue(cond.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}
/>
)
})
)}
: type === 'router_v2'
? routerRows.map((route, index) => (
<SubBlockRow
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}
/>
)
})
)}
{shouldShowDefaultHandles && <SubBlockRow title='error' />}
</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
type='source'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1073,16 +1073,16 @@ const sseHandlers: Record<string, SSEHandler> = {
// 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
// Only relevant if mode is 'build' (agent)
// Relevant in 'build' mode (with workflow) or 'superagent' mode (standalone)
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)
const def = name ? getTool(name) : undefined
const inst = getClientTool(id) as any
if (!def && !inst && name) {
// Check if this tool is auto-allowed
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
setTimeout(() => {
@@ -1090,9 +1090,10 @@ const sseHandlers: Record<string, SSEHandler> = {
}, 0)
} else {
// 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,
name,
mode,
})
}
}
@@ -1982,7 +1983,8 @@ export const useCopilotStore = create<CopilotStore>()(
messageId?: string
}
if (!workflowId) return
// Allow sending without workflowId in superagent mode
if (!workflowId && mode !== 'superagent') return
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
@@ -2053,8 +2055,8 @@ export const useCopilotStore = create<CopilotStore>()(
}
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
const apiMode: 'ask' | 'agent' | 'plan' | 'superagent' =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : mode === 'superagent' ? 'superagent' : 'agent'
const result = await sendStreamingMessage({
message: messageToSend,
userMessageId: userMessage.id,
@@ -2916,9 +2918,10 @@ export const useCopilotStore = create<CopilotStore>()(
},
executeIntegrationTool: async (toolCallId: string) => {
const { toolCallsById, workflowId } = get()
const { toolCallsById, workflowId, mode } = get()
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

View File

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