mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
4 Commits
main
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97839473c5 | ||
|
|
c369e04189 | ||
|
|
27c7bcf71d | ||
|
|
0bc8c0e1bc |
42
apps/sim/app/agent/page.tsx
Normal file
42
apps/sim/app/agent/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -51,6 +51,9 @@ interface TargetBlock {
|
||||
currentState?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the legacy router (block-based).
|
||||
*/
|
||||
export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => {
|
||||
const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request.
|
||||
|
||||
@@ -107,9 +110,88 @@ Example: "2acd9007-27e8-4510-a487-73d3b825e7c1"
|
||||
Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the system prompt for the port-based router (v2).
|
||||
* Instead of selecting a block by ID, it selects a route by evaluating all route descriptions.
|
||||
*/
|
||||
export const generateRouterV2Prompt = (
|
||||
context: string,
|
||||
routes: Array<{ id: string; title: string; value: string }>
|
||||
): string => {
|
||||
const routesInfo = routes
|
||||
.map(
|
||||
(route, index) => `
|
||||
Route ${index + 1}:
|
||||
ID: ${route.id}
|
||||
Description: ${route.value || 'No description provided'}
|
||||
---`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
|
||||
|
||||
Available Routes:
|
||||
${routesInfo}
|
||||
|
||||
Context to analyze:
|
||||
${context}
|
||||
|
||||
Instructions:
|
||||
1. Carefully analyze the context against each route's description
|
||||
2. Select the route that best matches the context's intent and requirements
|
||||
3. Consider the semantic meaning, not just keyword matching
|
||||
4. If multiple routes could match, choose the most specific one
|
||||
|
||||
Response Format:
|
||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
||||
Example: "route-abc123"
|
||||
|
||||
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get model options for both router versions.
|
||||
*/
|
||||
const getModelOptions = () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get API key condition for both router versions.
|
||||
*/
|
||||
const getApiKeyCondition = () => {
|
||||
return isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models],
|
||||
not: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Router Block (block-based routing).
|
||||
* Hidden from toolbar but still supported for existing workflows.
|
||||
*/
|
||||
export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
type: 'router',
|
||||
name: 'Router',
|
||||
name: 'Router (Legacy)',
|
||||
description: 'Route workflow',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
@@ -121,6 +203,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
hideFromToolbar: true, // Hide legacy version from toolbar
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'prompt',
|
||||
@@ -136,21 +219,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: () => {
|
||||
const providersState = useProvidersStore.getState()
|
||||
const baseModels = providersState.providers.base.models
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const vllmModels = providersState.providers.vllm.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const allModels = Array.from(
|
||||
new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels])
|
||||
)
|
||||
|
||||
return allModels.map((model) => {
|
||||
const icon = getProviderIcon(model)
|
||||
return { label: model, id: model, ...(icon && { icon }) }
|
||||
})
|
||||
},
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
@@ -173,22 +242,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
// Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth)
|
||||
condition: isHosted
|
||||
? {
|
||||
field: 'model',
|
||||
value: [...getHostedModels(), ...providers.vertex.models],
|
||||
not: true, // Show for all models EXCEPT those listed
|
||||
}
|
||||
: () => ({
|
||||
field: 'model',
|
||||
value: [
|
||||
...getCurrentOllamaModels(),
|
||||
...getCurrentVLLMModels(),
|
||||
...providers.vertex.models,
|
||||
],
|
||||
not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models
|
||||
}),
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
@@ -303,3 +357,185 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Router V2 Block (port-based routing).
|
||||
* Uses route definitions with descriptions instead of downstream block names.
|
||||
*/
|
||||
interface RouterV2Response extends ToolResponse {
|
||||
output: {
|
||||
context: string
|
||||
model: string
|
||||
tokens?: {
|
||||
prompt?: number
|
||||
completion?: number
|
||||
total?: number
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
selectedRoute: string
|
||||
selectedPath: {
|
||||
blockId: string
|
||||
blockType: string
|
||||
blockTitle: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RouterV2Block: BlockConfig<RouterV2Response> = {
|
||||
type: 'router_v2',
|
||||
name: 'Router',
|
||||
description: 'Route workflow based on context',
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Intelligently route workflow execution to different paths based on context analysis. Define multiple routes with descriptions, and an LLM will determine which route to take based on the provided context.',
|
||||
bestPractices: `
|
||||
- Write clear, specific descriptions for each route
|
||||
- The context field should contain all relevant information for routing decisions
|
||||
- Route descriptions should be mutually exclusive when possible
|
||||
- Use descriptive route names to make the workflow readable
|
||||
`,
|
||||
category: 'blocks',
|
||||
bgColor: '#28C43F',
|
||||
icon: ConnectIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'context',
|
||||
title: 'Context',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter the context to analyze for routing...',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'routes',
|
||||
type: 'router-input',
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
title: 'Model',
|
||||
type: 'combobox',
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
defaultValue: 'claude-sonnet-4-5',
|
||||
options: getModelOptions,
|
||||
},
|
||||
{
|
||||
id: 'vertexCredential',
|
||||
title: 'Google Cloud Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'vertex-ai',
|
||||
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
placeholder: 'Select Google Cloud account',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your API key',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: getApiKeyCondition(),
|
||||
},
|
||||
{
|
||||
id: 'azureEndpoint',
|
||||
title: 'Azure OpenAI Endpoint',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'https://your-resource.openai.azure.com',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'azureApiVersion',
|
||||
title: 'Azure API Version',
|
||||
type: 'short-input',
|
||||
placeholder: '2024-07-01-preview',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'openai_chat',
|
||||
'anthropic_chat',
|
||||
'google_chat',
|
||||
'xai_chat',
|
||||
'deepseek_chat',
|
||||
'deepseek_reasoner',
|
||||
],
|
||||
config: {
|
||||
tool: (params: Record<string, any>) => {
|
||||
const model = params.model || 'gpt-4o'
|
||||
if (!model) {
|
||||
throw new Error('No model selected')
|
||||
}
|
||||
const tool = getAllModelProviders()[model as ProviderId]
|
||||
if (!tool) {
|
||||
throw new Error(`Invalid model selected: ${model}`)
|
||||
}
|
||||
return tool
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
context: { type: 'string', description: 'Context for routing decision' },
|
||||
routes: { type: 'json', description: 'Route definitions with descriptions' },
|
||||
model: { type: 'string', description: 'AI model to use' },
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
vertexCredential: {
|
||||
type: 'string',
|
||||
description: 'Google Cloud OAuth credential ID for Vertex AI',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
context: { type: 'string', description: 'Context used for routing' },
|
||||
model: { type: 'string', description: 'Model used' },
|
||||
tokens: { type: 'json', description: 'Token usage' },
|
||||
cost: { type: 'json', description: 'Cost information' },
|
||||
selectedRoute: { type: 'string', description: 'Selected route ID' },
|
||||
selectedPath: { type: 'json', description: 'Selected routing path' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ import { RDSBlock } from '@/blocks/blocks/rds'
|
||||
import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ResendBlock } from '@/blocks/blocks/resend'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock } from '@/blocks/blocks/router'
|
||||
import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router'
|
||||
import { RssBlock } from '@/blocks/blocks/rss'
|
||||
import { S3Block } from '@/blocks/blocks/s3'
|
||||
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum BlockType {
|
||||
PARALLEL = 'parallel',
|
||||
LOOP = 'loop',
|
||||
ROUTER = 'router',
|
||||
ROUTER_V2 = 'router_v2',
|
||||
CONDITION = 'condition',
|
||||
|
||||
START_TRIGGER = 'start_trigger',
|
||||
@@ -271,7 +272,11 @@ export function isConditionBlockType(blockType: string | undefined): boolean {
|
||||
}
|
||||
|
||||
export function isRouterBlockType(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.ROUTER
|
||||
return blockType === BlockType.ROUTER || blockType === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
export function isRouterV2BlockType(blockType: string | undefined): boolean {
|
||||
return blockType === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
export function isAgentBlockType(blockType: string | undefined): boolean {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { EDGE, isConditionBlockType, isRouterBlockType } from '@/executor/constants'
|
||||
import {
|
||||
EDGE,
|
||||
isConditionBlockType,
|
||||
isRouterBlockType,
|
||||
isRouterV2BlockType,
|
||||
} from '@/executor/constants'
|
||||
import type { DAG } from '@/executor/dag/builder'
|
||||
import {
|
||||
buildBranchNodeId,
|
||||
@@ -19,10 +24,17 @@ interface ConditionConfig {
|
||||
condition: string
|
||||
}
|
||||
|
||||
interface RouterV2RouteConfig {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface EdgeMetadata {
|
||||
blockTypeMap: Map<string, string>
|
||||
conditionConfigMap: Map<string, ConditionConfig[]>
|
||||
routerBlockIds: Set<string>
|
||||
routerV2ConfigMap: Map<string, RouterV2RouteConfig[]>
|
||||
}
|
||||
|
||||
export class EdgeConstructor {
|
||||
@@ -58,6 +70,7 @@ export class EdgeConstructor {
|
||||
const blockTypeMap = new Map<string, string>()
|
||||
const conditionConfigMap = new Map<string, ConditionConfig[]>()
|
||||
const routerBlockIds = new Set<string>()
|
||||
const routerV2ConfigMap = new Map<string, RouterV2RouteConfig[]>()
|
||||
|
||||
for (const block of workflow.blocks) {
|
||||
const blockType = block.metadata?.id ?? ''
|
||||
@@ -69,12 +82,19 @@ export class EdgeConstructor {
|
||||
if (conditions) {
|
||||
conditionConfigMap.set(block.id, conditions)
|
||||
}
|
||||
} else if (isRouterV2BlockType(blockType)) {
|
||||
// Router V2 uses port-based routing with route configs
|
||||
const routes = this.parseRouterV2Config(block)
|
||||
if (routes) {
|
||||
routerV2ConfigMap.set(block.id, routes)
|
||||
}
|
||||
} else if (isRouterBlockType(blockType)) {
|
||||
// Legacy router uses target block IDs
|
||||
routerBlockIds.add(block.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { blockTypeMap, conditionConfigMap, routerBlockIds }
|
||||
return { blockTypeMap, conditionConfigMap, routerBlockIds, routerV2ConfigMap }
|
||||
}
|
||||
|
||||
private parseConditionConfig(block: any): ConditionConfig[] | null {
|
||||
@@ -100,6 +120,29 @@ export class EdgeConstructor {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRouterV2Config(block: any): RouterV2RouteConfig[] | null {
|
||||
try {
|
||||
const routesJson = block.config.params?.routes
|
||||
|
||||
if (typeof routesJson === 'string') {
|
||||
return JSON.parse(routesJson)
|
||||
}
|
||||
|
||||
if (Array.isArray(routesJson)) {
|
||||
return routesJson
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse router v2 config', {
|
||||
blockId: block.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private generateSourceHandle(
|
||||
source: string,
|
||||
target: string,
|
||||
@@ -123,6 +166,26 @@ export class EdgeConstructor {
|
||||
}
|
||||
}
|
||||
|
||||
// Router V2 uses port-based routing - handle is already set from UI (router-{routeId})
|
||||
// We don't modify it here, just validate it exists
|
||||
if (metadata.routerV2ConfigMap.has(source)) {
|
||||
// For router_v2, the sourceHandle should already be set from the UI
|
||||
// If not set and not an error handle, generate based on route index
|
||||
if (!handle || (!handle.startsWith(EDGE.ROUTER_PREFIX) && handle !== EDGE.ERROR)) {
|
||||
const routes = metadata.routerV2ConfigMap.get(source)
|
||||
if (routes && routes.length > 0) {
|
||||
const edgesFromRouter = workflow.connections.filter((c) => c.source === source)
|
||||
const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target)
|
||||
|
||||
if (edgeIndex >= 0 && edgeIndex < routes.length) {
|
||||
const correspondingRoute = routes[edgeIndex]
|
||||
handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy router uses target block ID
|
||||
if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) {
|
||||
handle = `${EDGE.ROUTER_PREFIX}${target}`
|
||||
}
|
||||
|
||||
@@ -4,29 +4,60 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { generateRouterPrompt } from '@/blocks/blocks/router'
|
||||
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants'
|
||||
import {
|
||||
BlockType,
|
||||
DEFAULTS,
|
||||
HTTP,
|
||||
isAgentBlockType,
|
||||
isRouterV2BlockType,
|
||||
ROUTER,
|
||||
} from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { calculateCost, getProviderFromModel } from '@/providers/utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
|
||||
const logger = createLogger('RouterBlockHandler')
|
||||
|
||||
interface RouteDefinition {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Router blocks that dynamically select execution paths.
|
||||
* Supports both legacy router (block-based) and router_v2 (port-based).
|
||||
*/
|
||||
export class RouterBlockHandler implements BlockHandler {
|
||||
constructor(private pathTracker?: any) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.ROUTER
|
||||
return block.metadata?.id === BlockType.ROUTER || block.metadata?.id === BlockType.ROUTER_V2
|
||||
}
|
||||
|
||||
async execute(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const isV2 = isRouterV2BlockType(block.metadata?.id)
|
||||
|
||||
if (isV2) {
|
||||
return this.executeV2(ctx, block, inputs)
|
||||
}
|
||||
|
||||
return this.executeLegacy(ctx, block, inputs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute legacy router (block-based routing).
|
||||
*/
|
||||
private async executeLegacy(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const targetBlocks = this.getTargetBlocks(ctx, block)
|
||||
|
||||
@@ -144,6 +175,168 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute router v2 (port-based routing).
|
||||
* Uses route definitions with descriptions instead of downstream block names.
|
||||
*/
|
||||
private async executeV2(
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<BlockOutput> {
|
||||
const routes = this.parseRoutes(inputs.routes)
|
||||
|
||||
if (routes.length === 0) {
|
||||
throw new Error('No routes defined for router')
|
||||
}
|
||||
|
||||
const routerConfig = {
|
||||
context: inputs.context,
|
||||
model: inputs.model || ROUTER.DEFAULT_MODEL,
|
||||
apiKey: inputs.apiKey,
|
||||
vertexProject: inputs.vertexProject,
|
||||
vertexLocation: inputs.vertexLocation,
|
||||
vertexCredential: inputs.vertexCredential,
|
||||
}
|
||||
|
||||
const providerId = getProviderFromModel(routerConfig.model)
|
||||
|
||||
try {
|
||||
const url = new URL('/api/providers', getBaseUrl())
|
||||
|
||||
const messages = [{ role: 'user', content: routerConfig.context }]
|
||||
const systemPrompt = generateRouterV2Prompt(routerConfig.context, routes)
|
||||
|
||||
let finalApiKey: string | undefined = routerConfig.apiKey
|
||||
if (providerId === 'vertex' && routerConfig.vertexCredential) {
|
||||
finalApiKey = await this.resolveVertexCredential(routerConfig.vertexCredential)
|
||||
}
|
||||
|
||||
const providerRequest: Record<string, any> = {
|
||||
provider: providerId,
|
||||
model: routerConfig.model,
|
||||
systemPrompt: systemPrompt,
|
||||
context: JSON.stringify(messages),
|
||||
temperature: ROUTER.INFERENCE_TEMPERATURE,
|
||||
apiKey: finalApiKey,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
}
|
||||
|
||||
if (providerId === 'vertex') {
|
||||
providerRequest.vertexProject = routerConfig.vertexProject
|
||||
providerRequest.vertexLocation = routerConfig.vertexLocation
|
||||
}
|
||||
|
||||
if (providerId === 'azure-openai') {
|
||||
providerRequest.azureEndpoint = inputs.azureEndpoint
|
||||
providerRequest.azureApiVersion = inputs.azureApiVersion
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': HTTP.CONTENT_TYPE.JSON,
|
||||
},
|
||||
body: JSON.stringify(providerRequest),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Provider API request failed with status ${response.status}`
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
if (errorData.error) {
|
||||
errorMessage = errorData.error
|
||||
}
|
||||
} catch (_e) {}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
const chosenRouteId = result.content.trim()
|
||||
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||
|
||||
if (!chosenRoute) {
|
||||
logger.error(
|
||||
`Invalid routing decision. Response content: "${result.content}", available routes:`,
|
||||
routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
)
|
||||
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
|
||||
}
|
||||
|
||||
// Find the target block connected to this route's handle
|
||||
const connection = ctx.workflow?.connections.find(
|
||||
(conn) => conn.source === block.id && conn.sourceHandle === `router-${chosenRoute.id}`
|
||||
)
|
||||
|
||||
const targetBlock = connection
|
||||
? ctx.workflow?.blocks.find((b) => b.id === connection.target)
|
||||
: null
|
||||
|
||||
const tokens = result.tokens || {
|
||||
input: DEFAULTS.TOKENS.PROMPT,
|
||||
output: DEFAULTS.TOKENS.COMPLETION,
|
||||
total: DEFAULTS.TOKENS.TOTAL,
|
||||
}
|
||||
|
||||
const cost = calculateCost(
|
||||
result.model,
|
||||
tokens.input || DEFAULTS.TOKENS.PROMPT,
|
||||
tokens.output || DEFAULTS.TOKENS.COMPLETION,
|
||||
false
|
||||
)
|
||||
|
||||
return {
|
||||
context: inputs.context,
|
||||
model: result.model,
|
||||
tokens: {
|
||||
input: tokens.input || DEFAULTS.TOKENS.PROMPT,
|
||||
output: tokens.output || DEFAULTS.TOKENS.COMPLETION,
|
||||
total: tokens.total || DEFAULTS.TOKENS.TOTAL,
|
||||
},
|
||||
cost: {
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
total: cost.total,
|
||||
},
|
||||
selectedRoute: chosenRoute.id,
|
||||
selectedPath: targetBlock
|
||||
? {
|
||||
blockId: targetBlock.id,
|
||||
blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE,
|
||||
blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE,
|
||||
}
|
||||
: {
|
||||
blockId: '',
|
||||
blockType: DEFAULTS.BLOCK_TYPE,
|
||||
blockTitle: chosenRoute.title,
|
||||
},
|
||||
} as BlockOutput
|
||||
} catch (error) {
|
||||
logger.error('Router V2 execution failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse routes from input (can be JSON string or array).
|
||||
*/
|
||||
private parseRoutes(input: any): RouteDefinition[] {
|
||||
try {
|
||||
if (typeof input === 'string') {
|
||||
return JSON.parse(input)
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse routes:', { input, error })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) {
|
||||
return ctx.workflow?.connections
|
||||
.filter((conn) => conn.source === block.id)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user