diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index c69e41ad09..4c1be78c88 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -13,6 +13,7 @@ import { hasAdminPermission } from '@/lib/permissions/utils' import { processStreamingBlockLogs } from '@/lib/tokenization' import { getEmailDomain } from '@/lib/urls/utils' import { decryptSecret, generateRequestId } from '@/lib/utils' +import { TriggerUtils } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks' import { Executor } from '@/executor' import type { BlockLog, ExecutionResult } from '@/executor/types' @@ -430,9 +431,10 @@ export async function executeWorkflowForChat( (acc, [id, block]) => { const blockConfig = getBlock(block.type) const isTriggerBlock = blockConfig?.category === 'triggers' + const isChatTrigger = block.type === 'chat_trigger' - // Skip trigger blocks during chat execution - if (!isTriggerBlock) { + // Keep all non-trigger blocks and also keep the chat_trigger block + if (!isTriggerBlock || isChatTrigger) { acc[id] = block } return acc @@ -487,8 +489,10 @@ export async function executeWorkflowForChat( // Filter edges to exclude connections to/from trigger blocks (same as manual execution) const triggerBlockIds = Object.keys(mergedStates).filter((id) => { - const blockConfig = getBlock(mergedStates[id].type) - return blockConfig?.category === 'triggers' + const type = mergedStates[id].type + const blockConfig = getBlock(type) + // Exclude chat_trigger from the list so its edges are preserved + return blockConfig?.category === 'triggers' && type !== 'chat_trigger' }) const filteredEdges = edges.filter( @@ -613,9 +617,29 @@ export async function executeWorkflowForChat( // Set up logging on the executor loggingSession.setupExecutor(executor) + // Determine the start block for chat execution + const startBlock = TriggerUtils.findStartBlock(mergedStates, 'chat') + + if (!startBlock) { + const errorMessage = + 'No Chat trigger configured for this workflow. Add a Chat Trigger block to enable chat execution.' + logger.error(`[${requestId}] ${errorMessage}`) + await loggingSession.safeCompleteWithError({ + endedAt: new Date().toISOString(), + totalDurationMs: 0, + error: { + message: errorMessage, + stackTrace: undefined, + }, + }) + throw new Error(errorMessage) + } + + const startBlockId = startBlock.blockId + let result try { - result = await executor.execute(workflowId) + result = await executor.execute(workflowId, startBlockId) } catch (error: any) { logger.error(`[${requestId}] Chat workflow execution failed:`, error) await loggingSession.safeCompleteWithError({ diff --git a/apps/sim/app/api/copilot/chat/route.test.ts b/apps/sim/app/api/copilot/chat/route.test.ts index 2a57ccb687..92a54281e9 100644 --- a/apps/sim/app/api/copilot/chat/route.test.ts +++ b/apps/sim/app/api/copilot/chat/route.test.ts @@ -220,13 +220,20 @@ describe('Copilot Chat API Route', () => { content: 'Hello', }, ], + chatMessages: [ + { + role: 'user', + content: 'Hello', + }, + ], workflowId: 'workflow-123', userId: 'user-123', stream: true, streamToolCalls: true, + model: 'gpt-5', mode: 'agent', messageId: 'mock-uuid-1234-5678', - depth: 0, + version: '1.0.0', chatId: 'chat-123', }), }) @@ -284,13 +291,19 @@ describe('Copilot Chat API Route', () => { { role: 'assistant', content: 'Previous response' }, { role: 'user', content: 'New message' }, ], + chatMessages: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' }, + { role: 'user', content: 'New message' }, + ], workflowId: 'workflow-123', userId: 'user-123', stream: true, streamToolCalls: true, + model: 'gpt-5', mode: 'agent', messageId: 'mock-uuid-1234-5678', - depth: 0, + version: '1.0.0', chatId: 'chat-123', }), }) @@ -337,13 +350,18 @@ describe('Copilot Chat API Route', () => { { role: 'system', content: 'User seems confused about the workflow' }, { role: 'user', content: 'Hello' }, ], + chatMessages: [ + { role: 'system', content: 'User seems confused about the workflow' }, + { role: 'user', content: 'Hello' }, + ], workflowId: 'workflow-123', userId: 'user-123', stream: true, streamToolCalls: true, + model: 'gpt-5', mode: 'agent', messageId: 'mock-uuid-1234-5678', - depth: 0, + version: '1.0.0', chatId: 'chat-123', }), }) @@ -427,13 +445,15 @@ describe('Copilot Chat API Route', () => { expect.objectContaining({ body: JSON.stringify({ messages: [{ role: 'user', content: 'What is this workflow?' }], + chatMessages: [{ role: 'user', content: 'What is this workflow?' }], workflowId: 'workflow-123', userId: 'user-123', stream: true, streamToolCalls: true, + model: 'gpt-5', mode: 'ask', messageId: 'mock-uuid-1234-5678', - depth: 0, + version: '1.0.0', chatId: 'chat-123', }), }) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 8bf4b676f2..0bc4d312a6 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -15,7 +15,7 @@ import { getCopilotModel } from '@/lib/copilot/config' import type { CopilotProviderConfig } from '@/lib/copilot/types' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent' +import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/sim-agent' import { generateChatTitle } from '@/lib/sim-agent/utils' import { createFileContent, isSupportedFileType } from '@/lib/uploads/file-utils' import { S3_COPILOT_CONFIG } from '@/lib/uploads/setup' @@ -38,8 +38,21 @@ const ChatMessageSchema = z.object({ userMessageId: z.string().optional(), // ID from frontend for the user message chatId: z.string().optional(), workflowId: z.string().min(1, 'Workflow ID is required'), + model: z + .enum([ + 'gpt-5-fast', + 'gpt-5', + 'gpt-5-medium', + 'gpt-5-high', + 'gpt-4o', + 'gpt-4.1', + 'o3', + 'claude-4-sonnet', + 'claude-4.1-opus', + ]) + .optional() + .default('gpt-5'), mode: z.enum(['ask', 'agent']).optional().default('agent'), - depth: z.number().int().min(0).max(3).optional().default(0), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), stream: z.boolean().optional().default(true), @@ -97,8 +110,8 @@ export async function POST(req: NextRequest) { userMessageId, chatId, workflowId, + model, mode, - depth, prefetch, createNewChat, stream, @@ -147,19 +160,6 @@ export async function POST(req: NextRequest) { } } - // Consolidation mapping: map negative depths to base depth with prefetch=true - let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined - let effectivePrefetch: boolean | undefined = prefetch - if (typeof effectiveDepth === 'number') { - if (effectiveDepth === -2) { - effectiveDepth = 1 - effectivePrefetch = true - } else if (effectiveDepth === -1) { - effectiveDepth = 0 - effectivePrefetch = true - } - } - // Handle chat context let currentChat: any = null let conversationHistory: any[] = [] @@ -366,16 +366,18 @@ export async function POST(req: NextRequest) { const requestPayload = { messages: messagesForAgent, + chatMessages: messages, // Full unfiltered messages array workflowId, userId: authenticatedUserId, stream: stream, streamToolCalls: true, + model: model, mode: mode, messageId: userMessageIdToUse, + version: SIM_AGENT_VERSION, ...(providerConfig ? { provider: providerConfig } : {}), ...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}), - ...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}), - ...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}), + ...(typeof prefetch === 'boolean' ? { prefetch: prefetch } : {}), ...(session?.user?.name && { userName: session.user.name }), ...(agentContexts.length > 0 && { context: agentContexts }), ...(actualChatId ? { chatId: actualChatId } : {}), @@ -384,6 +386,9 @@ export async function POST(req: NextRequest) { try { logger.info(`[${tracker.requestId}] About to call Sim Agent with context`, { context: (requestPayload as any).context, + messagesCount: messagesForAgent.length, + chatMessagesCount: messages.length, + hasConversationId: !!effectiveConversationId, }) } catch {} diff --git a/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts index f7ead3e522..d356162857 100644 --- a/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/sim/app/api/copilot/execute-copilot-server-tool/route.ts @@ -48,6 +48,7 @@ export async function POST(req: NextRequest) { return createBadRequestResponse('Invalid request body for execute-copilot-server-tool') } logger.error(`[${tracker.requestId}] Failed to execute server tool:`, error) - return createInternalServerErrorResponse('Failed to execute server tool') + const errorMessage = error instanceof Error ? error.message : 'Failed to execute server tool' + return createInternalServerErrorResponse(errorMessage) } } diff --git a/apps/sim/app/api/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.test.ts index ebdd92dba7..05a58f9a00 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.test.ts @@ -292,7 +292,7 @@ describe('Workflow Execution API Route', () => { const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() - expect(executeMock).toHaveBeenCalledWith('workflow-id') + expect(executeMock).toHaveBeenCalledWith('workflow-id', 'starter-id') }) /** @@ -337,7 +337,7 @@ describe('Workflow Execution API Route', () => { const Executor = (await import('@/executor')).Executor expect(Executor).toHaveBeenCalled() - expect(executeMock).toHaveBeenCalledWith('workflow-id') + expect(executeMock).toHaveBeenCalledWith('workflow-id', 'starter-id') expect(Executor).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 73c10606c4..857ba08a94 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -14,6 +14,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret, generateRequestId } from '@/lib/utils' import { loadDeployedWorkflowState } from '@/lib/workflows/db-helpers' +import { TriggerUtils } from '@/lib/workflows/triggers' import { createHttpResponseFromBlock, updateWorkflowRunCounts, @@ -272,6 +273,32 @@ async function executeWorkflow( true // Enable validation during execution ) + // Determine API trigger start block + // Direct API execution ONLY works with API trigger blocks (or legacy starter in api/run mode) + const startBlock = TriggerUtils.findStartBlock(mergedStates, 'api', false) // isChildWorkflow = false + + if (!startBlock) { + logger.error(`[${requestId}] No API trigger configured for this workflow`) + throw new Error( + 'No API trigger configured for this workflow. Add an API Trigger block or use a Start block in API mode.' + ) + } + + const startBlockId = startBlock.blockId + const triggerBlock = startBlock.block + + // Check if the API trigger has any outgoing connections (except for legacy starter blocks) + // Legacy starter blocks have their own validation in the executor + if (triggerBlock.type !== 'starter') { + const outgoingConnections = serializedWorkflow.connections.filter( + (conn) => conn.source === startBlockId + ) + if (outgoingConnections.length === 0) { + logger.error(`[${requestId}] API trigger has no outgoing connections`) + throw new Error('API Trigger block must be connected to other blocks to execute') + } + } + const executor = new Executor({ workflow: serializedWorkflow, currentBlockStates: processedBlockStates, @@ -287,7 +314,7 @@ async function executeWorkflow( // Set up logging on the executor loggingSession.setupExecutor(executor) - const result = await executor.execute(workflowId) + const result = await executor.execute(workflowId, startBlockId) // Check if we got a StreamingExecution result (with stream + execution properties) // For API routes, we only care about the ExecutionResult part, not the stream diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 4507e795f0..4cceb9d834 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -12,7 +12,7 @@ import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' +import { sanitizeAgentToolsInBlocks, validateWorkflowState } from '@/lib/workflows/validation' import { getUserId } from '@/app/api/auth/oauth/utils' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' @@ -240,6 +240,65 @@ async function upsertCustomToolsFromBlocks( } } +/** + * Convert blocks with 'inputs' field to standard 'subBlocks' structure + * This handles trigger blocks that may come from YAML/copilot with legacy format + */ +function normalizeBlockStructure(blocks: Record): Record { + const normalizedBlocks: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const normalizedBlock = { ...block } + + // Normalize position coordinates (handle both uppercase and lowercase) + if (block.position) { + normalizedBlock.position = { + x: block.position.x ?? block.position.X ?? 0, + y: block.position.y ?? block.position.Y ?? 0, + } + } + + // Convert any inputs map into subBlocks for consistency (applies to all blocks) + if (block.inputs) { + // Convert inputs.inputFormat to subBlocks.inputFormat + if (block.inputs.inputFormat) { + if (!normalizedBlock.subBlocks) { + normalizedBlock.subBlocks = {} + } + + normalizedBlock.subBlocks.inputFormat = { + id: 'inputFormat', + type: 'input-format', + value: block.inputs.inputFormat, + } + } + + // Copy all inputs fields to subBlocks (creating entries as needed) + for (const [inputKey, inputValue] of Object.entries(block.inputs)) { + if (!normalizedBlock.subBlocks) { + normalizedBlock.subBlocks = {} + } + if (!normalizedBlock.subBlocks[inputKey]) { + normalizedBlock.subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', // Default type, may need adjustment based on actual field + value: inputValue, + } + } else { + normalizedBlock.subBlocks[inputKey].value = inputValue + } + } + + // Remove the inputs field after conversion + normalizedBlock.inputs = undefined + } + + normalizedBlocks[blockId] = normalizedBlock + } + + return normalizedBlocks +} + /** * PUT /api/workflows/[id]/yaml * Consolidated YAML workflow saving endpoint @@ -344,39 +403,76 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) } + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + if (workflowState.blocks) { + workflowState.blocks = normalizeBlockStructure(workflowState.blocks) + } + + // Validate the workflow state before persisting + const validation = validateWorkflowState(workflowState, { sanitize: true }) + + if (!validation.valid) { + logger.error(`[${requestId}] Workflow validation failed`, { + errors: validation.errors, + warnings: validation.warnings, + }) + return NextResponse.json({ + success: false, + message: 'Invalid workflow structure', + errors: validation.errors, + warnings: validation.warnings || [], + }) + } + + // Use sanitized state if available + const finalWorkflowState = validation.sanitizedState || workflowState + + if (validation.warnings.length > 0) { + logger.warn(`[${requestId}] Workflow validation warnings`, { + warnings: validation.warnings, + }) + } + // Ensure all blocks have required fields - Object.values(workflowState.blocks).forEach((block: any) => { - if (block.enabled === undefined) { - block.enabled = true + Object.entries(finalWorkflowState.blocks).forEach(([blockId, block]) => { + const blockData = block as any + if (!blockData.id) blockData.id = blockId + if (!blockData.position) { + blockData.position = { x: 0, y: 0 } + } + if (blockData.enabled === undefined) { + blockData.enabled = true } - if (block.horizontalHandles === undefined) { - block.horizontalHandles = true + if (blockData.horizontalHandles === undefined) { + blockData.horizontalHandles = true } - if (block.isWide === undefined) { - block.isWide = false + if (blockData.isWide === undefined) { + blockData.isWide = false } - if (block.height === undefined) { - block.height = 0 + if (blockData.height === undefined) { + blockData.height = 0 } - if (!block.subBlocks) { - block.subBlocks = {} + if (!blockData.subBlocks) { + blockData.subBlocks = {} } - if (!block.outputs) { - block.outputs = {} + if (!blockData.outputs) { + blockData.outputs = {} } }) - const blocks = Object.values(workflowState.blocks) as Array<{ + const blocks = Object.values(finalWorkflowState.blocks) as Array<{ id: string type: string name: string position: { x: number; y: number } subBlocks?: Record + inputs?: Record + triggerMode?: boolean data?: Record parentId?: string extent?: string }> - const edges = workflowState.edges + const edges = finalWorkflowState.edges const warnings = conversionResult.warnings || [] // Create workflow state @@ -454,6 +550,25 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ }) } + // Handle blocks that have inputs instead of subBlocks (from YAML/copilot format) + // This is especially important for trigger configuration + if (block.inputs) { + Object.entries(block.inputs).forEach(([inputKey, inputValue]) => { + const matchingSubBlock = blockConfig.subBlocks.find((sb) => sb.id === inputKey) + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: + matchingSubBlock?.type || + (inputKey === 'triggerConfig' ? 'trigger-config' : 'short-input'), + value: inputValue, + } + } else if (inputValue !== undefined) { + subBlocks[inputKey].value = inputValue + } + }) + } + // Set up outputs from block configuration const outputs = resolveOutputType(blockConfig.outputs) @@ -476,10 +591,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ isWide: false, advancedMode: false, height: 0, + triggerMode: block.triggerMode || false, // Preserve triggerMode from imported block data: blockData, } - logger.debug(`[${requestId}] Processed regular block: ${block.id} -> ${newId}`) + logger.debug(`[${requestId}] Processed regular block: ${block.id} -> ${newId}`, { + blockType: block.type, + hasTriggerMode: block.triggerMode, + hasInputs: !!block.inputs, + inputKeys: block.inputs ? Object.keys(block.inputs) : [], + subBlockKeys: Object.keys(subBlocks), + }) } else { logger.warn(`[${requestId}] Unknown block type: ${block.type}`) } diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 3dfee755dd..62045859aa 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { workflow, workflowBlocks, workspace } from '@sim/db/schema' +import { workflow, workspace } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -95,132 +95,31 @@ export async function POST(req: NextRequest) { const { name, description, color, workspaceId, folderId } = CreateWorkflowSchema.parse(body) const workflowId = crypto.randomUUID() - const starterId = crypto.randomUUID() const now = new Date() logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`) - await db.transaction(async (tx) => { - await tx.insert(workflow).values({ - id: workflowId, - userId: session.user.id, - workspaceId: workspaceId || null, - folderId: folderId || null, - name, - description, - color, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - collaborators: [], - runCount: 0, - variables: {}, - isPublished: false, - marketplaceData: null, - }) - - await tx.insert(workflowBlocks).values({ - id: starterId, - workflowId: workflowId, - type: 'starter', - name: 'Start', - positionX: '100', - positionY: '100', - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: '95', - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown', - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input', - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input', - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown', - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input', - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input', - value: '', - }, - hourlyMinute: { - id: 'hourlyMinute', - type: 'short-input', - value: '', - }, - dailyTime: { - id: 'dailyTime', - type: 'short-input', - value: '', - }, - weeklyDay: { - id: 'weeklyDay', - type: 'dropdown', - value: 'MON', - }, - weeklyDayTime: { - id: 'weeklyDayTime', - type: 'short-input', - value: '', - }, - monthlyDay: { - id: 'monthlyDay', - type: 'short-input', - value: '', - }, - monthlyTime: { - id: 'monthlyTime', - type: 'short-input', - value: '', - }, - cronExpression: { - id: 'cronExpression', - type: 'short-input', - value: '', - }, - timezone: { - id: 'timezone', - type: 'dropdown', - value: 'UTC', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - createdAt: now, - updatedAt: now, - }) - - logger.info( - `[${requestId}] Successfully created workflow ${workflowId} with start block in workflow_blocks table` - ) + await db.insert(workflow).values({ + id: workflowId, + userId: session.user.id, + workspaceId: workspaceId || null, + folderId: folderId || null, + name, + description, + color, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + collaborators: [], + runCount: 0, + variables: {}, + isPublished: false, + marketplaceData: null, }) + logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`) + return NextResponse.json({ id: workflowId, name, diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index a42e35cc00..736256d1e3 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { permissions, workflow, workflowBlocks, workspace } from '@sim/db/schema' +import { permissions, workflow, workspace } from '@sim/db/schema' import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' @@ -110,9 +110,7 @@ async function createWorkspace(userId: string, name: string) { updatedAt: now, }) - // Create initial workflow for the workspace with start block - const starterId = crypto.randomUUID() - + // Create initial workflow for the workspace (empty canvas) // Create the workflow await tx.insert(workflow).values({ id: workflowId, @@ -133,61 +131,7 @@ async function createWorkspace(userId: string, name: string) { marketplaceData: null, }) - // Insert the start block into workflow_blocks table - await tx.insert(workflowBlocks).values({ - id: starterId, - workflowId: workflowId, - type: 'starter', - name: 'Start', - positionX: '100', - positionY: '100', - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - height: '95', - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown', - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input', - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input', - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown', - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input', - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input', - value: '', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - createdAt: now, - updatedAt: now, - }) + // No blocks are inserted - empty canvas logger.info( `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` diff --git a/apps/sim/app/api/yaml/diff/create/route.ts b/apps/sim/app/api/yaml/diff/create/route.ts index 627d539e13..81148c6520 100644 --- a/apps/sim/app/api/yaml/diff/create/route.ts +++ b/apps/sim/app/api/yaml/diff/create/route.ts @@ -4,6 +4,7 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent' import { generateRequestId } from '@/lib/utils' +import { validateWorkflowState } from '@/lib/workflows/validation' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -61,6 +62,59 @@ const CreateDiffRequestSchema = z.object({ .optional(), }) +/** + * Convert blocks with 'inputs' field to standard 'subBlocks' structure + * This handles trigger blocks that may come from YAML/copilot with legacy format + */ +function normalizeBlockStructure(blocks: Record): Record { + const normalizedBlocks: Record = {} + + for (const [blockId, block] of Object.entries(blocks)) { + const normalizedBlock = { ...block } + + // Check if this is a trigger block with 'inputs' field + if ( + block.inputs && + (block.type === 'api_trigger' || + block.type === 'input_trigger' || + block.type === 'starter' || + block.type === 'chat_trigger' || + block.type === 'generic_webhook') + ) { + // Convert inputs.inputFormat to subBlocks.inputFormat + if (block.inputs.inputFormat) { + if (!normalizedBlock.subBlocks) { + normalizedBlock.subBlocks = {} + } + + normalizedBlock.subBlocks.inputFormat = { + id: 'inputFormat', + type: 'input-format', + value: block.inputs.inputFormat, + } + } + + // Copy any other inputs fields to subBlocks + for (const [inputKey, inputValue] of Object.entries(block.inputs)) { + if (inputKey !== 'inputFormat' && !normalizedBlock.subBlocks[inputKey]) { + normalizedBlock.subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', // Default type, may need adjustment based on actual field + value: inputValue, + } + } + } + + // Remove the inputs field after conversion + normalizedBlock.inputs = undefined + } + + normalizedBlocks[blockId] = normalizedBlock + } + + return normalizedBlocks +} + export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -202,6 +256,46 @@ export async function POST(request: NextRequest) { const finalResult = result if (result.success && result.diff?.proposedState) { + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + if (result.diff.proposedState.blocks) { + result.diff.proposedState.blocks = normalizeBlockStructure(result.diff.proposedState.blocks) + } + + // Validate the proposed workflow state + const validation = validateWorkflowState(result.diff.proposedState, { sanitize: true }) + + if (!validation.valid) { + logger.error(`[${requestId}] Proposed workflow state validation failed`, { + errors: validation.errors, + warnings: validation.warnings, + }) + return NextResponse.json( + { + success: false, + errors: validation.errors, + }, + { status: 400 } + ) + } + + // Use sanitized state if available + if (validation.sanitizedState) { + result.diff.proposedState = validation.sanitizedState + } + + if (validation.warnings.length > 0) { + logger.warn(`[${requestId}] Proposed workflow validation warnings`, { + warnings: validation.warnings, + }) + // Include warnings in the response + if (!result.warnings) { + result.warnings = [] + } + result.warnings.push(...validation.warnings) + } + + logger.info(`[${requestId}] Successfully created diff with normalized and validated blocks`) + // First, fix parent-child relationships based on edges const blocks = result.diff.proposedState.blocks const edges = result.diff.proposedState.edges || [] @@ -271,6 +365,9 @@ export async function POST(request: NextRequest) { if (result.success && result.blocks && !result.diff) { logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`) + // Normalize blocks that use 'inputs' field to standard 'subBlocks' structure + result.blocks = normalizeBlockStructure(result.blocks) + // First, fix parent-child relationships based on edges const blocks = result.blocks const edges = result.edges || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx index d589b78cec..59f0b5aaa2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -85,10 +85,16 @@ export function DeployModal({ let inputFormatExample = '' try { const blocks = Object.values(useWorkflowStore.getState().blocks) + + // Check for API trigger block first (takes precedence) + const apiTriggerBlock = blocks.find((block) => block.type === 'api_trigger') + // Fall back to legacy starter block const starterBlock = blocks.find((block) => block.type === 'starter') - if (starterBlock) { - const inputFormat = useSubBlockStore.getState().getValue(starterBlock.id, 'inputFormat') + const targetBlock = apiTriggerBlock || starterBlock + + if (targetBlock) { + const inputFormat = useSubBlockStore.getState().getValue(targetBlock.id, 'inputFormat') if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { const exampleData: Record = {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx index cdad8823ad..de576b808c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx @@ -330,6 +330,25 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) { if (event === 'final' && data) { const result = data as ExecutionResult + + // If final result is a failure, surface error and stop + if ('success' in result && !result.success) { + addMessage({ + content: `Error: ${result.error || 'Workflow execution failed'}`, + workflowId: activeWorkflowId, + type: 'workflow', + }) + + // Clear any existing message streams + for (const msgId of messageIdMap.values()) { + finalizeMessageStream(msgId) + } + messageIdMap.clear() + + // Stop processing + return + } + const nonStreamingLogs = result.logs?.filter((log) => !messageIdMap.has(log.blockId)) || [] @@ -343,34 +362,25 @@ export function Chat({ chatMessage, setChatMessage }: ChatProps) { const blockIdForOutput = extractBlockIdFromOutputId(outputId) const path = extractPathFromOutputId(outputId, blockIdForOutput) const log = nonStreamingLogs.find((l) => l.blockId === blockIdForOutput) - if (log) { - let outputValue: any = log.output - + let output = log.output if (path) { - // Parse JSON content safely - outputValue = parseOutputContentSafely(outputValue) - + output = parseOutputContentSafely(output) const pathParts = path.split('.') + let current = output for (const part of pathParts) { - if ( - outputValue && - typeof outputValue === 'object' && - part in outputValue - ) { - outputValue = outputValue[part] + if (current && typeof current === 'object' && part in current) { + current = current[part] } else { - outputValue = undefined + current = undefined break } } + output = current } - if (outputValue !== undefined) { + if (output !== undefined) { addMessage({ - content: - typeof outputValue === 'string' - ? outputValue - : `\`\`\`json\n${JSON.stringify(outputValue, null, 2)}\n\`\`\``, + content: typeof output === 'string' ? output : JSON.stringify(output), workflowId: activeWorkflowId, type: 'workflow', }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx index 82ccf17ac8..3062c08732 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/console-entry/console-entry.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { format } from 'date-fns' import { AlertCircle, + AlertTriangle, Check, ChevronDown, ChevronUp, @@ -369,8 +370,10 @@ export function ConsoleEntry({ entry, consoleWidth }: ConsoleEntryProps) { } }, [showCopySuccess]) - const BlockIcon = blockConfig?.icon - const blockColor = blockConfig?.bgColor || '#6B7280' + // Special handling for serialization errors + const BlockIcon = entry.blockType === 'serializer' ? AlertTriangle : blockConfig?.icon + const blockColor = + entry.blockType === 'serializer' ? '#EF4444' : blockConfig?.bgColor || '#6B7280' // Handle image load error callback const handleImageLoadError = (hasError: boolean) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index 426913eb76..7d2846af81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -456,46 +456,6 @@ const CopilotMessage: FC = memo( ) : null}
- {hasCheckpoints && ( -
- {showRestoreConfirmation ? ( -
- - -
- ) : ( - - )} -
- )}
{/* Message content in purple box */}
= memo( })()}
+ {hasCheckpoints && ( +
+ {showRestoreConfirmation ? ( +
+ Restore Checkpoint? + + +
+ ) : ( + + )} +
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 7fb6402018..633cc0a35a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -51,7 +51,6 @@ import { import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' -import { CopilotSlider } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/copilot-slider' import { useCopilotStore } from '@/stores/copilot/store' import type { ChatContext } from '@/stores/copilot/types' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -122,6 +121,7 @@ const UserInput = forwardRef( const [isDragging, setIsDragging] = useState(false) const [dragCounter, setDragCounter] = useState(0) const textareaRef = useRef(null) + const overlayRef = useRef(null) const fileInputRef = useRef(null) const [showMentionMenu, setShowMentionMenu] = useState(false) const mentionMenuRef = useRef(null) @@ -319,15 +319,37 @@ const UserInput = forwardRef( // Auto-resize textarea and toggle vertical scroll when exceeding max height useEffect(() => { const textarea = textareaRef.current + const overlay = overlayRef.current if (textarea) { const maxHeight = 120 textarea.style.height = 'auto' const nextHeight = Math.min(textarea.scrollHeight, maxHeight) textarea.style.height = `${nextHeight}px` textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' + + // Also update overlay height to match + if (overlay) { + overlay.style.height = `${nextHeight}px` + overlay.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden' + } } }, [message]) + // Sync scroll position between textarea and overlay + useEffect(() => { + const textarea = textareaRef.current + const overlay = overlayRef.current + + if (!textarea || !overlay) return + + const handleScroll = () => { + overlay.scrollTop = textarea.scrollTop + } + + textarea.addEventListener('scroll', handleScroll) + return () => textarea.removeEventListener('scroll', handleScroll) + }, []) + // Close mention menu on outside click useEffect(() => { if (!showMentionMenu) return @@ -1754,55 +1776,44 @@ const UserInput = forwardRef( return 'Agent' } - // Depth toggle state comes from global store; access via useCopilotStore - const { agentDepth, agentPrefetch, setAgentDepth, setAgentPrefetch } = useCopilotStore() - - // Ensure MAX mode is off for Fast and Balanced depths - useEffect(() => { - if (agentDepth < 2 && !agentPrefetch) { - setAgentPrefetch(true) - } - }, [agentDepth, agentPrefetch, setAgentPrefetch]) - - const cycleDepth = () => { - // 8 modes: depths 0-3, each with prefetch off/on. Cycle depth, then toggle prefetch when wrapping. - const nextDepth = agentDepth === 3 ? 0 : ((agentDepth + 1) as 0 | 1 | 2 | 3) - if (nextDepth === 0 && agentDepth === 3) { - setAgentPrefetch(!agentPrefetch) - } - setAgentDepth(nextDepth) - } + // Model selection state comes from global store; access via useCopilotStore + const { selectedModel, agentPrefetch, setSelectedModel, setAgentPrefetch } = useCopilotStore() + + // Model configurations with their display names + const modelOptions = [ + { value: 'gpt-5-fast', label: 'gpt-5-fast' }, + { value: 'gpt-5', label: 'gpt-5' }, + { value: 'gpt-5-medium', label: 'gpt-5-medium' }, + { value: 'gpt-5-high', label: 'gpt-5-high' }, + { value: 'gpt-4o', label: 'gpt-4o' }, + { value: 'gpt-4.1', label: 'gpt-4.1' }, + { value: 'o3', label: 'o3' }, + { value: 'claude-4-sonnet', label: 'claude-4-sonnet' }, + { value: 'claude-4.1-opus', label: 'claude-4.1-opus' }, + ] as const const getCollapsedModeLabel = () => { - const base = getDepthLabelFor(agentDepth) - return !agentPrefetch ? `${base} MAX` : base - } - - const getDepthLabelFor = (value: 0 | 1 | 2 | 3) => { - return value === 0 ? 'Fast' : value === 1 ? 'Balanced' : value === 2 ? 'Advanced' : 'Behemoth' + const model = modelOptions.find((m) => m.value === selectedModel) + return model ? model.label : 'GPT-5 Default' } - // Removed descriptive suffixes; concise labels only - const getDepthDescription = (value: 0 | 1 | 2 | 3) => { - if (value === 0) - return 'Fastest and cheapest. Good for small edits, simple workflows, and small tasks' - if (value === 1) return 'Balances speed and reasoning. Good fit for most tasks' - if (value === 2) - return 'More reasoning for larger workflows and complex edits, still balanced for speed' - return 'Maximum reasoning power. Best for complex workflow building and debugging' - } - - const getDepthIconFor = (value: 0 | 1 | 2 | 3) => { + const getModelIcon = () => { const colorClass = !agentPrefetch ? 'text-[var(--brand-primary-hover-hex)]' : 'text-muted-foreground' - if (value === 0) return - if (value === 1) return - if (value === 2) return - return - } - const getDepthIcon = () => getDepthIconFor(agentDepth) + // Match the dropdown icon logic exactly + if (['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(selectedModel)) { + return + } + if (['gpt-5', 'gpt-5-medium', 'claude-4-sonnet'].includes(selectedModel)) { + return + } + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)) { + return + } + return + } const scrollActiveItemIntoView = (index: number) => { const container = menuListRef.current @@ -2116,8 +2127,11 @@ const UserInput = forwardRef( {/* Textarea Field with overlay */}
{/* Highlight overlay */} -
-
+            
+
                 {(() => {
                   const elements: React.ReactNode[] = []
                   const remaining = message
@@ -2163,8 +2177,8 @@ const UserInput = forwardRef(
               placeholder={isDragging ? 'Drop files here...' : effectivePlaceholder}
               disabled={disabled}
               rows={1}
-              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent px-[2px] py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
-              style={{ height: 'auto' }}
+              className='relative z-[2] mb-2 min-h-[32px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[2px] py-1 font-sans text-sm text-transparent leading-[1.25rem] caret-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
+              style={{ height: 'auto', wordBreak: 'break-word' }}
             />
 
             {showMentionMenu && (
@@ -3057,7 +3071,7 @@ const UserInput = forwardRef(
                     {getModeText()}
                   
                 
-                
+                
                   
                     
@@ -3131,85 +3145,166 @@ const UserInput = forwardRef( )} title='Choose mode' > - {getDepthIcon()} - {getCollapsedModeLabel()} + {getModelIcon()} + + {getCollapsedModeLabel()} + {!agentPrefetch && + !['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel) && ( + MAX + )} + - + -
-
-
- MAX mode - - - - - - Significantly increases depth of reasoning -
- - Only available in Advanced and Behemoth modes - -
-
-
- { - if (agentDepth < 2) return - setAgentPrefetch(!checked) - }} - /> -
-
-
-
-
+
+
- Mode -
- {getDepthIconFor(agentDepth)} - - {getDepthLabelFor(agentDepth)} - +
+ MAX mode + + + + + + Significantly increases depth of reasoning +
+ + Only available for advanced models + +
+
-
-
- - setAgentDepth((val?.[0] ?? 0) as 0 | 1 | 2 | 3) + { + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(selectedModel)) + return + setAgentPrefetch(!checked) + }} /> -
-
-
-
+
+
+
-
- {getDepthDescription(agentDepth)} +
+
+
+ Model +
+
+ {/* Helper function to get icon for a model */} + {(() => { + const getModelIcon = (modelValue: string) => { + if ( + ['gpt-5-high', 'o3', 'claude-4.1-opus'].includes(modelValue) + ) { + return ( + + ) + } + if ( + ['gpt-5', 'gpt-5-medium', 'claude-4-sonnet'].includes( + modelValue + ) + ) { + return + } + if (['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes(modelValue)) { + return + } + return
+ } + + const renderModelOption = ( + option: (typeof modelOptions)[number] + ) => ( + { + setSelectedModel(option.value) + // Automatically turn off max mode for fast models (Zap icon) + if ( + ['gpt-4o', 'gpt-4.1', 'gpt-5-fast'].includes( + option.value + ) && + !agentPrefetch + ) { + setAgentPrefetch(true) + } + }} + className={cn( + 'flex h-7 items-center gap-1.5 px-2 py-1 text-left text-xs', + selectedModel === option.value ? 'bg-muted/50' : '' + )} + > + {getModelIcon(option.value)} + {option.label} + + ) + + return ( + <> + {/* OpenAI Models */} +
+
+ OpenAI +
+
+ {modelOptions + .filter((option) => + [ + 'gpt-5-fast', + 'gpt-5', + 'gpt-5-medium', + 'gpt-5-high', + 'gpt-4o', + 'gpt-4.1', + 'o3', + ].includes(option.value) + ) + .map(renderModelOption)} +
+
+ + {/* Anthropic Models */} +
+
+ Anthropic +
+
+ {modelOptions + .filter((option) => + ['claude-4-sonnet', 'claude-4.1-opus'].includes( + option.value + ) + ) + .map(renderModelOption)} +
+
+ + ) + })()} +
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list.tsx new file mode 100644 index 0000000000..a98e7c3159 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list.tsx @@ -0,0 +1,249 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Info, Plus, Search, X } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import { getAllTriggerBlocks, getTriggerDisplayName } from '@/lib/workflows/trigger-utils' + +const logger = createLogger('TriggerList') + +interface TriggerListProps { + onSelect: (triggerId: string, enableTriggerMode?: boolean) => void + className?: string +} + +export function TriggerList({ onSelect, className }: TriggerListProps) { + const [searchQuery, setSearchQuery] = useState('') + const [showList, setShowList] = useState(false) + const listRef = useRef(null) + + // Get all trigger options from the centralized source + const triggerOptions = useMemo(() => getAllTriggerBlocks(), []) + + // Handle escape key + useEffect(() => { + if (!showList) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + logger.info('Closing trigger list via escape key') + setShowList(false) + setSearchQuery('') + } + } + + document.addEventListener('keydown', handleEscape) + + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [showList]) + + const filteredOptions = useMemo(() => { + if (!searchQuery.trim()) return triggerOptions + + const query = searchQuery.toLowerCase() + return triggerOptions.filter( + (option) => + option.name.toLowerCase().includes(query) || + option.description.toLowerCase().includes(query) + ) + }, [searchQuery, triggerOptions]) + + const coreOptions = useMemo( + () => filteredOptions.filter((opt) => opt.category === 'core'), + [filteredOptions] + ) + + const integrationOptions = useMemo( + () => filteredOptions.filter((opt) => opt.category === 'integration'), + [filteredOptions] + ) + + const handleTriggerClick = (triggerId: string, enableTriggerMode?: boolean) => { + logger.info('Trigger selected', { triggerId, enableTriggerMode }) + onSelect(triggerId, enableTriggerMode) + // Reset state after selection + setShowList(false) + setSearchQuery('') + } + + const handleClose = () => { + logger.info('Closing trigger list via X button') + setShowList(false) + setSearchQuery('') + } + + const TriggerItem = ({ trigger }: { trigger: (typeof triggerOptions)[0] }) => { + const Icon = trigger.icon + + return ( +
+
handleTriggerClick(trigger.id, trigger.enableTriggerMode)} + className='flex flex-1 items-center gap-[10px]' + > +
+ {Icon ? ( + + ) : ( +
+ )} +
+ + {getTriggerDisplayName(trigger.id)} + +
+ + + + + +

{trigger.description}

+
+
+
+ ) + } + + return ( +
+ {!showList ? ( + /* Initial Button State */ + + ) : ( + /* Trigger List View */ +
+ {/* Search - matching search modal exactly */} +
+ + setSearchQuery(e.target.value)} + className='!font-[350] border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0' + autoFocus + /> +
+ + {/* Close button */} + + + {/* Trigger List */} +
+
+ {/* Core Triggers Section */} + {coreOptions.length > 0 && ( +
+

+ Core Triggers +

+
+ {/* Display triggers in a 3-column grid */} +
+ {coreOptions.map((trigger) => ( + + ))} +
+
+
+ )} + + {/* Integration Triggers Section */} + {integrationOptions.length > 0 && ( +
+

+ Integration Triggers +

+
+ {/* Display triggers in a 3-column grid */} +
+ {integrationOptions.map((trigger) => ( + + ))} +
+
+
+ )} + + {filteredOptions.length === 0 && ( +
+

No results found for "{searchQuery}"

+
+ )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx new file mode 100644 index 0000000000..2b8c2a55ed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog.tsx @@ -0,0 +1,60 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +export enum TriggerWarningType { + DUPLICATE_TRIGGER = 'duplicate_trigger', + LEGACY_INCOMPATIBILITY = 'legacy_incompatibility', +} + +interface TriggerWarningDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + triggerName: string + type: TriggerWarningType +} + +export function TriggerWarningDialog({ + open, + onOpenChange, + triggerName, + type, +}: TriggerWarningDialogProps) { + const getTitle = () => { + switch (type) { + case TriggerWarningType.LEGACY_INCOMPATIBILITY: + return 'Cannot mix trigger types' + case TriggerWarningType.DUPLICATE_TRIGGER: + return `Only one ${triggerName} trigger allowed` + } + } + + const getDescription = () => { + switch (type) { + case TriggerWarningType.LEGACY_INCOMPATIBILITY: + return 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' + case TriggerWarningType.DUPLICATE_TRIGGER: + return `A workflow can only have one ${triggerName} trigger block. Please remove the existing one before adding a new one.` + } + } + + return ( + + + + {getTitle()} + {getDescription()} + + + onOpenChange(false)}>Got it + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts index d132f528e0..2d16bb19ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/index.ts @@ -10,6 +10,7 @@ export { EvalInput } from './eval-input' export { FileSelectorInput } from './file-selector/file-selector-input' export { FileUpload } from './file-upload' export { FolderSelectorInput } from './folder-selector/components/folder-selector-input' +export { InputMapping } from './input-mapping/input-mapping' export { KnowledgeBaseSelector } from './knowledge-base-selector/knowledge-base-selector' export { LongInput } from './long-input' export { McpDynamicArgs } from './mcp-dynamic-args/mcp-dynamic-args' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx new file mode 100644 index 0000000000..86e362c05b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/input-mapping/input-mapping.tsx @@ -0,0 +1,339 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { formatDisplayText } from '@/components/ui/formatted-text' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { cn } from '@/lib/utils' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface InputFormatField { + name: string + type?: string +} + +interface InputTriggerBlock { + type: 'input_trigger' + subBlocks?: { + inputFormat?: { value?: InputFormatField[] } + } +} + +interface StarterBlockLegacy { + type: 'starter' + subBlocks?: { + inputFormat?: { value?: InputFormatField[] } + } + config?: { + params?: { + inputFormat?: InputFormatField[] + } + } +} + +function isInputTriggerBlock(value: unknown): value is InputTriggerBlock { + return ( + !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'input_trigger' + ) +} + +function isStarterBlock(value: unknown): value is StarterBlockLegacy { + return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'starter' +} + +function isInputFormatField(value: unknown): value is InputFormatField { + if (typeof value !== 'object' || value === null) return false + if (!('name' in value)) return false + const { name, type } = value as { name: unknown; type?: unknown } + if (typeof name !== 'string' || name.trim() === '') return false + if (type !== undefined && typeof type !== 'string') return false + return true +} + +interface InputMappingProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: any + disabled?: boolean +} + +// Simple mapping UI: for each field in child Input Trigger's inputFormat, render an input with TagDropdown support +export function InputMapping({ + blockId, + subBlockId, + isPreview = false, + previewValue, + disabled = false, +}: InputMappingProps) { + const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId) + const [selectedWorkflowId] = useSubBlockValue(blockId, 'workflowId') + + const { workflows } = useWorkflowRegistry.getState() + + // Fetch child workflow state via registry API endpoint, using cached metadata when possible + // Here we rely on live store; the serializer/executor will resolve at runtime too. + // We only need the inputFormat from an Input Trigger in the selected child workflow state. + const [childInputFields, setChildInputFields] = useState>( + [] + ) + + useEffect(() => { + let isMounted = true + const controller = new AbortController() + async function fetchChildSchema() { + try { + if (!selectedWorkflowId) { + if (isMounted) setChildInputFields([]) + return + } + const res = await fetch(`/api/workflows/${selectedWorkflowId}`, { + signal: controller.signal, + }) + if (!res.ok) { + if (isMounted) setChildInputFields([]) + return + } + const { data } = await res.json() + const blocks = (data?.state?.blocks as Record) || {} + // Prefer new input_trigger + const triggerEntry = Object.entries(blocks).find(([, b]) => isInputTriggerBlock(b)) + if (triggerEntry && isInputTriggerBlock(triggerEntry[1])) { + const inputFormat = triggerEntry[1].subBlocks?.inputFormat?.value + if (Array.isArray(inputFormat)) { + const fields = (inputFormat as unknown[]) + .filter(isInputFormatField) + .map((f) => ({ name: f.name, type: f.type })) + if (isMounted) setChildInputFields(fields) + return + } + } + + // Fallback: legacy starter block inputFormat (subBlocks or config.params) + const starterEntry = Object.entries(blocks).find(([, b]) => isStarterBlock(b)) + if (starterEntry && isStarterBlock(starterEntry[1])) { + const starter = starterEntry[1] + const subBlockFormat = starter.subBlocks?.inputFormat?.value + const legacyParamsFormat = starter.config?.params?.inputFormat + const chosen = Array.isArray(subBlockFormat) ? subBlockFormat : legacyParamsFormat + if (Array.isArray(chosen)) { + const fields = (chosen as unknown[]) + .filter(isInputFormatField) + .map((f) => ({ name: f.name, type: f.type })) + if (isMounted) setChildInputFields(fields) + return + } + } + + if (isMounted) setChildInputFields([]) + } catch { + if (isMounted) setChildInputFields([]) + } + } + fetchChildSchema() + return () => { + isMounted = false + controller.abort() + } + }, [selectedWorkflowId]) + + const valueObj: Record = useMemo(() => { + if (isPreview && previewValue && typeof previewValue === 'object') return previewValue + if (mapping && typeof mapping === 'object') return mapping as Record + try { + if (typeof mapping === 'string') return JSON.parse(mapping) + } catch {} + return {} + }, [mapping, isPreview, previewValue]) + + const update = (field: string, value: string) => { + if (disabled) return + const updated = { ...valueObj, [field]: value } + setMapping(updated) + } + + if (!selectedWorkflowId) { + return ( +
+ + + +

No workflow selected

+

+ Select a workflow above to configure inputs +

+
+ ) + } + + if (!childInputFields || childInputFields.length === 0) { + return ( +
+ + + +

No input fields defined

+

+ The selected workflow needs an Input Trigger with defined fields +

+
+ ) + } + + return ( +
+ {childInputFields.map((field) => { + return ( + update(field.name, value)} + blockId={blockId} + subBlockId={subBlockId} + disabled={isPreview || disabled} + /> + ) + })} +
+ ) +} + +// Individual field component with TagDropdown support +function InputMappingField({ + fieldName, + fieldType, + value, + onChange, + blockId, + subBlockId, + disabled, +}: { + fieldName: string + fieldType?: string + value: string + onChange: (value: string) => void + blockId: string + subBlockId: string + disabled: boolean +}) { + const [showTags, setShowTags] = useState(false) + const [cursorPosition, setCursorPosition] = useState(0) + const inputRef = useRef(null) + const overlayRef = useRef(null) + + const handleChange = (e: React.ChangeEvent) => { + if (disabled) { + e.preventDefault() + return + } + + const newValue = e.target.value + const newCursorPosition = e.target.selectionStart ?? 0 + + onChange(newValue) + setCursorPosition(newCursorPosition) + + // Check for tag trigger + const tagTrigger = checkTagTrigger(newValue, newCursorPosition) + setShowTags(tagTrigger.show) + } + + // Sync scroll position between input and overlay + const handleScroll = (e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollLeft = e.currentTarget.scrollLeft + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setShowTags(false) + } + } + + const handleTagSelect = (newValue: string) => { + onChange(newValue) + } + + return ( +
+
+ + {fieldType && ( + + {fieldType} + + )} +
+
+ { + setShowTags(false) + }} + onBlur={() => { + setShowTags(false) + }} + onScroll={handleScroll} + onKeyDown={handleKeyDown} + autoComplete='off' + style={{ overflowX: 'auto' }} + disabled={disabled} + /> +
+
+ {formatDisplayText(value)} +
+
+ + { + setShowTags(false) + }} + /> +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index dcc48297e1..496d21216b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from 'react' -import { PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react' +import React, { useCallback, useEffect, useState } from 'react' +import { AlertCircle, PlusIcon, Server, WrenchIcon, XIcon } from 'lucide-react' import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -374,6 +374,44 @@ function FileUploadSyncWrapper({ ) } +// Error boundary component for tool input +class ToolInputErrorBoundary extends React.Component< + { children: React.ReactNode; blockName?: string }, + { hasError: boolean; error?: Error } +> { + constructor(props: any) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error('ToolInput error:', error, info) + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + Tool Configuration Error +
+

+ {this.props.blockName ? `Block "${this.props.blockName}": ` : ''} + Invalid tool reference. Please check the workflow configuration. +

+
+ ) + } + + return this.props.children + } +} + export function ToolInput({ blockId, subBlockId, @@ -475,10 +513,18 @@ export function ToolInput({ // Fallback: create options from tools.access return block.tools.access.map((toolId) => { - const toolParams = getToolParametersConfig(toolId) - return { - id: toolId, - label: toolParams?.toolConfig?.name || toolId, + try { + const toolParams = getToolParametersConfig(toolId) + return { + id: toolId, + label: toolParams?.toolConfig?.name || toolId, + } + } catch (error) { + console.error(`Error getting tool config for ${toolId}:`, error) + return { + id: toolId, + label: toolId, + } } }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx index c8a49a5d89..4511803a25 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx @@ -18,6 +18,7 @@ import { FileUpload, FolderSelectorInput, InputFormat, + InputMapping, KnowledgeBaseSelector, LongInput, McpDynamicArgs, @@ -450,6 +451,17 @@ export function SubBlock({ /> ) } + case 'input-mapping': { + return ( + + ) + } case 'response-format': return ( ) { isShowingDiff, id, ]) + // Always call hooks to maintain consistent hook order + const storeHorizontalHandles = useWorkflowStore( + (state) => state.blocks[id]?.horizontalHandles ?? true + ) + const storeIsWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false) + const storeBlockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0) + const storeBlockAdvancedMode = useWorkflowStore( + (state) => state.blocks[id]?.advancedMode ?? false + ) + const storeBlockTriggerMode = useWorkflowStore((state) => state.blocks[id]?.triggerMode ?? false) + + // Get block properties from currentWorkflow when in diff mode, otherwise from workflow store const horizontalHandles = data.isPreview ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal - : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency - const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false) - const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0) + : currentWorkflow.isDiffMode + ? (currentWorkflow.blocks[id]?.horizontalHandles ?? true) + : storeHorizontalHandles + + const isWide = currentWorkflow.isDiffMode + ? (currentWorkflow.blocks[id]?.isWide ?? false) + : storeIsWide + + const blockHeight = currentWorkflow.isDiffMode + ? (currentWorkflow.blocks[id]?.height ?? 0) + : storeBlockHeight + // Get per-block webhook status by checking if webhook is configured const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -157,8 +178,14 @@ export function WorkflowBlock({ id, data }: NodeProps) { ) const blockWebhookStatus = !!(hasWebhookProvider && hasWebhookPath) - const blockAdvancedMode = useWorkflowStore((state) => state.blocks[id]?.advancedMode ?? false) - const blockTriggerMode = useWorkflowStore((state) => state.blocks[id]?.triggerMode ?? false) + const blockAdvancedMode = currentWorkflow.isDiffMode + ? (currentWorkflow.blocks[id]?.advancedMode ?? false) + : storeBlockAdvancedMode + + // Get triggerMode from currentWorkflow blocks when in diff mode, otherwise from workflow store + const blockTriggerMode = currentWorkflow.isDiffMode + ? (currentWorkflow.blocks[id]?.triggerMode ?? false) + : storeBlockTriggerMode // Local UI state for diff mode controls const [diffIsWide, setDiffIsWide] = useState(isWide) @@ -660,7 +687,10 @@ export function WorkflowBlock({ id, data }: NodeProps) { {/* Block Header */}
0 && 'border-b' + )} onMouseDown={(e) => { e.stopPropagation() }} @@ -891,7 +921,7 @@ export function WorkflowBlock({ id, data }: NodeProps) {

Description

{config.longDescription}

- {config.outputs && ( + {config.outputs && Object.keys(config.outputs).length > 0 && (

Output

@@ -929,90 +959,92 @@ export function WorkflowBlock({ id, data }: NodeProps) { ) )} - - - - - - {!userPermissions.canEdit && !currentWorkflow.isDiffMode - ? userPermissions.isOfflineMode - ? 'Connection lost - please refresh' - : 'Read-only mode' - : displayIsWide - ? 'Narrow Block' - : 'Expand Block'} - - + {subBlockRows.length > 0 && ( + + + + + + {!userPermissions.canEdit && !currentWorkflow.isDiffMode + ? userPermissions.isOfflineMode + ? 'Connection lost - please refresh' + : 'Read-only mode' + : displayIsWide + ? 'Narrow Block' + : 'Expand Block'} + + + )}
- {/* Block Content */} -
{ - e.stopPropagation() - }} - > - {subBlockRows.length > 0 - ? subBlockRows.map((row, rowIndex) => ( -
- {row.map((subBlock, blockIndex) => ( -
- -
- ))} -
- )) - : null} -
+ {/* Block Content - Only render if there are subblocks */} + {subBlockRows.length > 0 && ( +
{ + e.stopPropagation() + }} + > + {subBlockRows.map((row, rowIndex) => ( +
+ {row.map((subBlock, blockIndex) => ( +
+ +
+ ))} +
+ ))} +
+ )} {/* Output Handle */} {type !== 'condition' && type !== 'response' && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 8fa2a1321f..e5ca24009d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' -import { getBlock } from '@/blocks' +import { TriggerUtils } from '@/lib/workflows/triggers' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' @@ -447,7 +447,30 @@ export function useWorkflowExecution() { ) } } catch (error: any) { - controller.error(error) + // Create a proper error result for logging + const errorResult = { + success: false, + error: error.message || 'Workflow execution failed', + output: {}, + logs: [], + metadata: { + duration: 0, + startTime: new Date().toISOString(), + source: 'chat' as const, + }, + } + + // Send the error as final event so downstream handlers can treat it uniformly + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ event: 'final', data: errorResult })}\n\n`) + ) + + // Persist the error to logs so it shows up in the logs page + persistLogs(executionId, errorResult).catch((err) => + logger.error('Error persisting error logs:', err) + ) + + // Do not error the controller to allow consumers to process the final event } finally { controller.close() setIsExecuting(false) @@ -560,22 +583,14 @@ export function useWorkflowExecution() { } }) - // Filter out trigger blocks for manual execution + // Do not filter out trigger blocks; executor may need to start from them const filteredStates = Object.entries(mergedStates).reduce( (acc, [id, block]) => { - // Skip blocks with undefined type if (!block || !block.type) { logger.warn(`Skipping block with undefined type: ${id}`, block) return acc } - - const blockConfig = getBlock(block.type) - const isTriggerBlock = blockConfig?.category === 'triggers' - - // Skip trigger blocks during manual execution - if (!isTriggerBlock) { - acc[id] = block - } + acc[id] = block return acc }, {} as typeof mergedStates @@ -632,15 +647,8 @@ export function useWorkflowExecution() { {} as Record ) - // Filter edges to exclude connections to/from trigger blocks - const triggerBlockIds = Object.keys(mergedStates).filter((id) => { - const blockConfig = getBlock(mergedStates[id].type) - return blockConfig?.category === 'triggers' - }) - - const filteredEdges = workflowEdges.filter( - (edge) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target) - ) + // Keep edges intact to allow execution starting from trigger blocks + const filteredEdges = workflowEdges // Derive subflows from the current filtered graph to avoid stale state const runtimeLoops = generateLoopBlocks(filteredStates) @@ -663,12 +671,158 @@ export function useWorkflowExecution() { selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } - // Create executor options + // Determine start block and workflow input based on execution type + let startBlockId: string | undefined + let finalWorkflowInput = workflowInput + + if (isExecutingFromChat) { + // For chat execution, find the appropriate chat trigger + const startBlock = TriggerUtils.findStartBlock(filteredStates, 'chat') + + if (!startBlock) { + throw new Error(TriggerUtils.getTriggerValidationMessage('chat', 'missing')) + } + + startBlockId = startBlock.blockId + } else { + // For manual editor runs: look for Manual trigger OR API trigger + const entries = Object.entries(filteredStates) + + // Find manual triggers and API triggers + const manualTriggers = TriggerUtils.findTriggersByType(filteredStates, 'manual') + const apiTriggers = TriggerUtils.findTriggersByType(filteredStates, 'api') + + logger.info('Manual run trigger check:', { + manualTriggersCount: manualTriggers.length, + apiTriggersCount: apiTriggers.length, + manualTriggers: manualTriggers.map((t) => ({ + type: t.type, + name: t.name, + isLegacy: t.type === 'starter', + })), + apiTriggers: apiTriggers.map((t) => ({ + type: t.type, + name: t.name, + isLegacy: t.type === 'starter', + })), + }) + + let selectedTrigger: any = null + let selectedBlockId: string | null = null + + // Check for API triggers first (they take precedence over manual triggers) + if (apiTriggers.length === 1) { + selectedTrigger = apiTriggers[0] + const blockEntry = entries.find(([, block]) => block === selectedTrigger) + if (blockEntry) { + selectedBlockId = blockEntry[0] + + // Extract test values from the API trigger's inputFormat + if (selectedTrigger.type === 'api_trigger' || selectedTrigger.type === 'starter') { + const inputFormatValue = selectedTrigger.subBlocks?.inputFormat?.value + if (Array.isArray(inputFormatValue)) { + const testInput: Record = {} + inputFormatValue.forEach((field: any) => { + if (field && typeof field === 'object' && field.name && field.value !== undefined) { + testInput[field.name] = field.value + } + }) + + // Use the test input as workflow input + if (Object.keys(testInput).length > 0) { + finalWorkflowInput = testInput + logger.info('Using API trigger test values for manual run:', testInput) + } + } + } + } + } else if (apiTriggers.length > 1) { + const error = new Error('Multiple API Trigger blocks found. Keep only one.') + logger.error('Multiple API triggers found') + setIsExecuting(false) + throw error + } else if (manualTriggers.length === 1) { + // No API trigger, check for manual trigger + selectedTrigger = manualTriggers[0] + const blockEntry = entries.find(([, block]) => block === selectedTrigger) + if (blockEntry) { + selectedBlockId = blockEntry[0] + } + } else if (manualTriggers.length > 1) { + const error = new Error('Multiple Input Trigger blocks found. Keep only one.') + logger.error('Multiple input triggers found') + setIsExecuting(false) + throw error + } else { + // Fallback: Check for legacy starter block + const starterBlock = Object.values(filteredStates).find((block) => block.type === 'starter') + if (starterBlock) { + // Found a legacy starter block, use it as a manual trigger + const blockEntry = Object.entries(filteredStates).find( + ([, block]) => block === starterBlock + ) + if (blockEntry) { + selectedBlockId = blockEntry[0] + selectedTrigger = starterBlock + logger.info('Using legacy starter block for manual run') + } + } + + if (!selectedBlockId || !selectedTrigger) { + const error = new Error('Manual run requires an Input Trigger or API Trigger block') + logger.error('No input or API triggers found for manual run') + setIsExecuting(false) + throw error + } + } + + if (selectedBlockId && selectedTrigger) { + startBlockId = selectedBlockId + + // Check if the trigger has any outgoing connections (except for legacy starter blocks) + // Legacy starter blocks have their own validation in the executor + if (selectedTrigger.type !== 'starter') { + const outgoingConnections = workflowEdges.filter((edge) => edge.source === startBlockId) + if (outgoingConnections.length === 0) { + const triggerName = selectedTrigger.name || selectedTrigger.type + const error = new Error(`${triggerName} must be connected to other blocks to execute`) + logger.error('Trigger has no outgoing connections', { triggerName, startBlockId }) + setIsExecuting(false) + throw error + } + } + + logger.info('Trigger found for manual run:', { + startBlockId, + triggerType: selectedTrigger.type, + triggerName: selectedTrigger.name, + isLegacyStarter: selectedTrigger.type === 'starter', + usingTestValues: selectedTrigger.type === 'api_trigger', + }) + } + } + + // If we don't have a valid startBlockId at this point, throw an error + if (!startBlockId) { + const error = new Error('No valid trigger block found to start execution') + logger.error('No startBlockId found after trigger search') + setIsExecuting(false) + throw error + } + + // Log the final startBlockId + logger.info('Final execution setup:', { + startBlockId, + isExecutingFromChat, + hasWorkflowInput: !!workflowInput, + }) + + // Create executor options with the final workflow input const executorOptions: ExecutorOptions = { workflow, currentBlockStates, envVarValues, - workflowInput, + workflowInput: finalWorkflowInput, workflowVariables, contextExtensions: { stream: isExecutingFromChat, @@ -687,8 +841,8 @@ export function useWorkflowExecution() { const newExecutor = new Executor(executorOptions) setExecutor(newExecutor) - // Execute workflow - return newExecutor.execute(activeWorkflowId || '') + // Execute workflow with the determined start block + return newExecutor.execute(activeWorkflowId || '', startBlockId) } const handleExecutionError = (error: any, options?: { executionId?: string }) => { @@ -729,7 +883,7 @@ export function useWorkflowExecution() { try { // Prefer attributing to specific subflow if we have a structured error let blockId = 'serialization' - let blockName = 'Serialization' + let blockName = 'Workflow' let blockType = 'serializer' if (error instanceof WorkflowValidationError) { blockId = error.blockId || blockId diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/lib/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/lib/workflow-execution-utils.ts index 26e0f5a886..71652bb2e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/lib/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/lib/workflow-execution-utils.ts @@ -6,7 +6,6 @@ import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console/logger' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' -import { getBlock } from '@/blocks' import type { BlockOutput } from '@/blocks/types' import { Executor } from '@/executor' import type { ExecutionResult, StreamingExecution } from '@/executor/types' @@ -131,26 +130,9 @@ export async function executeWorkflowWithLogging( // Merge subblock states from the appropriate store const mergedStates = mergeSubblockState(validBlocks) - // Filter out trigger blocks for manual execution - const filteredStates = Object.entries(mergedStates).reduce( - (acc, [id, block]) => { - // Skip blocks with undefined type - if (!block || !block.type) { - logger.warn(`Skipping block with undefined type: ${id}`, block) - return acc - } - - const blockConfig = getBlock(block.type) - const isTriggerBlock = blockConfig?.category === 'triggers' - - // Skip trigger blocks during manual execution - if (!isTriggerBlock) { - acc[id] = block - } - return acc - }, - {} as typeof mergedStates - ) + // Don't filter out trigger blocks - let the executor handle them properly + // The standard executor has TriggerBlockHandler that knows how to handle triggers + const filteredStates = mergedStates const currentBlockStates = Object.entries(filteredStates).reduce( (acc, [id, block]) => { @@ -186,15 +168,9 @@ export async function executeWorkflowWithLogging( {} as Record ) - // Filter edges to exclude connections to/from trigger blocks - const triggerBlockIds = Object.keys(mergedStates).filter((id) => { - const blockConfig = getBlock(mergedStates[id].type) - return blockConfig?.category === 'triggers' - }) - - const filteredEdges = workflowEdges.filter( - (edge: any) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target) - ) + // Don't filter edges - let all connections remain intact + // The executor's routing system will handle execution paths properly + const filteredEdges = workflowEdges // Create serialized workflow with filtered blocks and edges const workflow = new Serializer().serializeWorkflow( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts index fa0c44c93a..427738aaff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -206,6 +206,18 @@ export async function applyAutoLayoutAndUpdateStore( loops: newWorkflowState.loops || {}, parallels: newWorkflowState.parallels || {}, deploymentStatuses: newWorkflowState.deploymentStatuses || {}, + // Sanitize edges: remove null/empty handle fields to satisfy schema (optional strings) + edges: (newWorkflowState.edges || []).map((edge: any) => { + const { sourceHandle, targetHandle, ...rest } = edge || {} + const sanitized: any = { ...rest } + if (typeof sourceHandle === 'string' && sourceHandle.length > 0) { + sanitized.sourceHandle = sourceHandle + } + if (typeof targetHandle === 'string' && targetHandle.length > 0) { + sanitized.targetHandle = targetHandle + } + return sanitized + }), } // Save the updated workflow state to the database diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 0324584d29..d001ec5f80 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -13,6 +13,7 @@ import ReactFlow, { } from 'reactflow' import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console/logger' +import { TriggerUtils } from '@/lib/workflows/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls' @@ -20,6 +21,11 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { FloatingControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/floating-controls/floating-controls' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' +import { TriggerList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list' +import { + TriggerWarningDialog, + TriggerWarningType, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' @@ -77,6 +83,17 @@ const WorkflowContent = React.memo(() => { // Enhanced edge selection with parent context and unique identifier const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + // State for trigger warning dialog + const [triggerWarning, setTriggerWarning] = useState<{ + open: boolean + triggerName: string + type: TriggerWarningType + }>({ + open: false, + triggerName: '', + type: TriggerWarningType.DUPLICATE_TRIGGER, + }) + // Hooks const params = useParams() const router = useRouter() @@ -107,6 +124,11 @@ const WorkflowContent = React.memo(() => { // Extract workflow data from the abstraction const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow + // Check if workflow is empty (no blocks) + const isWorkflowEmpty = useMemo(() => { + return Object.keys(blocks).length === 0 + }, [blocks]) + // Get diff analysis for edge reconstruction const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore() @@ -565,7 +587,7 @@ const WorkflowContent = React.memo(() => { return } - const { type } = event.detail + const { type, enableTriggerMode } = event.detail if (!type) return if (type === 'connectionBlock') return @@ -637,7 +659,10 @@ const WorkflowContent = React.memo(() => { // Create a new block with a unique ID const id = crypto.randomUUID() - const name = getUniqueBlockName(blockConfig.name, blocks) + // Prefer semantic default names for triggers; then ensure unique numbering centrally + const defaultTriggerName = TriggerUtils.getDefaultTriggerName(type) + const baseName = defaultTriggerName || blockConfig.name + const name = getUniqueBlockName(baseName, blocks) // Auto-connect logic const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled @@ -661,8 +686,38 @@ const WorkflowContent = React.memo(() => { } } + // Centralized trigger constraints + const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type) + if (additionIssue) { + if (additionIssue.issue === 'legacy') { + setTriggerWarning({ + open: true, + triggerName: additionIssue.triggerName, + type: TriggerWarningType.LEGACY_INCOMPATIBILITY, + }) + } else { + setTriggerWarning({ + open: true, + triggerName: additionIssue.triggerName, + type: TriggerWarningType.DUPLICATE_TRIGGER, + }) + } + return + } + // Add the block to the workflow with auto-connect edge - addBlock(id, type, name, centerPosition, undefined, undefined, undefined, autoConnectEdge) + // Enable trigger mode if this is a trigger-capable block from the triggers tab + addBlock( + id, + type, + name, + centerPosition, + undefined, + undefined, + undefined, + autoConnectEdge, + enableTriggerMode + ) } window.addEventListener('add-block-from-toolbar', handleAddBlockFromToolbar as EventListener) @@ -681,8 +736,35 @@ const WorkflowContent = React.memo(() => { findClosestOutput, determineSourceHandle, effectivePermissions.canEdit, + setTriggerWarning, ]) + // Handler for trigger selection from list + const handleTriggerSelect = useCallback( + (triggerId: string, enableTriggerMode?: boolean) => { + // Get the trigger name + const triggerName = TriggerUtils.getDefaultTriggerName(triggerId) || triggerId + + // Create the trigger block at the center of the viewport + const centerPosition = project({ x: window.innerWidth / 2, y: window.innerHeight / 2 }) + const id = `${triggerId}_${Date.now()}` + + // Add the trigger block with trigger mode if specified + addBlock( + id, + triggerId, + triggerName, + centerPosition, + undefined, + undefined, + undefined, + undefined, + enableTriggerMode || false + ) + }, + [project, addBlock] + ) + // Update the onDrop handler const onDrop = useCallback( (event: React.DragEvent) => { @@ -784,8 +866,14 @@ const WorkflowContent = React.memo(() => { // Generate id and name here so they're available in all code paths const id = crypto.randomUUID() + // Prefer semantic default names for triggers; then ensure unique numbering centrally + const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type) const baseName = - data.type === 'loop' ? 'Loop' : data.type === 'parallel' ? 'Parallel' : blockConfig!.name + data.type === 'loop' + ? 'Loop' + : data.type === 'parallel' + ? 'Parallel' + : defaultTriggerNameDrop || blockConfig!.name const name = getUniqueBlockName(baseName, blocks) if (containerInfo) { @@ -868,6 +956,20 @@ const WorkflowContent = React.memo(() => { // Immediate resize without delay resizeLoopNodesWrapper() } else { + // Centralized trigger constraints + const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type) + if (dropIssue) { + setTriggerWarning({ + open: true, + triggerName: dropIssue.triggerName, + type: + dropIssue.issue === 'legacy' + ? TriggerWarningType.LEGACY_INCOMPATIBILITY + : TriggerWarningType.DUPLICATE_TRIGGER, + }) + return + } + // Regular auto-connect logic const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled let autoConnectEdge @@ -888,7 +990,19 @@ const WorkflowContent = React.memo(() => { } // Regular canvas drop with auto-connect edge - addBlock(id, data.type, name, position, undefined, undefined, undefined, autoConnectEdge) + // Use enableTriggerMode from drag data if present (when dragging from Triggers tab) + const enableTriggerMode = data.enableTriggerMode || false + addBlock( + id, + data.type, + name, + position, + undefined, + undefined, + undefined, + autoConnectEdge, + enableTriggerMode + ) } } catch (err) { logger.error('Error dropping block:', { err }) @@ -903,6 +1017,7 @@ const WorkflowContent = React.memo(() => { determineSourceHandle, isPointInLoopNodeWrapper, getNodes, + setTriggerWarning, ] ) @@ -1815,6 +1930,19 @@ const WorkflowContent = React.memo(() => { {/* Show DiffControls if diff is available (regardless of current view mode) */} + + {/* Trigger warning dialog */} + setTriggerWarning({ ...triggerWarning, open })} + triggerName={triggerWarning.triggerName} + type={triggerWarning.type} + /> + + {/* Trigger list for empty workflows - only show after workflow has loaded */} + {isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx index 98a9f2fdf0..ad3573d24a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/toolbar/components/toolbar-block/toolbar-block.tsx @@ -7,9 +7,14 @@ import type { BlockConfig } from '@/blocks/types' export type ToolbarBlockProps = { config: BlockConfig disabled?: boolean + enableTriggerMode?: boolean } -export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { +export function ToolbarBlock({ + config, + disabled = false, + enableTriggerMode = false, +}: ToolbarBlockProps) { const userPermissions = useUserPermissionsContext() const handleDragStart = (e: React.DragEvent) => { @@ -17,7 +22,13 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { e.preventDefault() return } - e.dataTransfer.setData('application/json', JSON.stringify({ type: config.type })) + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ + type: config.type, + enableTriggerMode, + }) + ) e.dataTransfer.effectAllowed = 'move' } @@ -29,10 +40,11 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) { const event = new CustomEvent('add-block-from-toolbar', { detail: { type: config.type, + enableTriggerMode, }, }) window.dispatchEvent(event) - }, [config.type, disabled]) + }, [config.type, disabled, enableTriggerMode]) const blockContent = (
{ - const allBlocks = getAllBlocks() + // Get blocks based on the active tab using centralized logic + const sourceBlocks = activeTab === 'blocks' ? getBlocksForSidebar() : getTriggersForSidebar() // Filter blocks based on search query - const filteredBlocks = allBlocks.filter((block) => { - if (block.type === 'starter' || block.hideFromToolbar) return false - - return ( + const filteredBlocks = sourceBlocks.filter((block) => { + const matchesSearch = !searchQuery.trim() || block.name.toLowerCase().includes(searchQuery.toLowerCase()) || block.description.toLowerCase().includes(searchQuery.toLowerCase()) - ) + + return matchesSearch }) - // Separate blocks by category: 'blocks', 'tools', and 'triggers' + // Separate blocks by category const regularBlockConfigs = filteredBlocks.filter((block) => block.category === 'blocks') const toolConfigs = filteredBlocks.filter((block) => block.category === 'tools') - const triggerConfigs = filteredBlocks.filter((block) => block.category === 'triggers') + // For triggers tab, include both 'triggers' category and tools with trigger capability + const triggerConfigs = + activeTab === 'triggers' + ? filteredBlocks + : filteredBlocks.filter((block) => block.category === 'triggers') // Create regular block items and sort alphabetically const regularBlockItems: BlockItem[] = regularBlockConfigs @@ -54,23 +64,25 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: })) .sort((a, b) => a.name.localeCompare(b.name)) - // Create special blocks (loop and parallel) if they match search + // Create special blocks (loop and parallel) only for blocks tab const specialBlockItems: BlockItem[] = [] - if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) { - specialBlockItems.push({ - name: 'Loop', - type: 'loop', - isCustom: true, - }) - } - - if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) { - specialBlockItems.push({ - name: 'Parallel', - type: 'parallel', - isCustom: true, - }) + if (activeTab === 'blocks') { + if (!searchQuery.trim() || 'loop'.toLowerCase().includes(searchQuery.toLowerCase())) { + specialBlockItems.push({ + name: 'Loop', + type: 'loop', + isCustom: true, + }) + } + + if (!searchQuery.trim() || 'parallel'.toLowerCase().includes(searchQuery.toLowerCase())) { + specialBlockItems.push({ + name: 'Parallel', + type: 'parallel', + isCustom: true, + }) + } } // Sort special blocks alphabetically @@ -95,65 +107,106 @@ export function Toolbar({ userPermissions, isWorkspaceSelectorVisible = false }: tools: toolConfigs, triggers: triggerBlockItems, } - }, [searchQuery]) + }, [searchQuery, activeTab]) return (
- {/* Search */} -
-
- - setSearchQuery(e.target.value)} - className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - /> + {/* Tabs */} + +
+
+ + +
-
- - {/* Content */} - -
- {/* Regular Blocks Section */} - {regularBlocks.map((block) => ( - - ))} - - {/* Special Blocks Section (Loop & Parallel) */} - {specialBlocks.map((block) => { - if (block.type === 'loop') { - return - } - if (block.type === 'parallel') { - return - } - return null - })} - - {/* Triggers Section */} - {triggers.map((trigger) => ( - - ))} - {/* Tools Section */} - {tools.map((tool) => ( - - ))} + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className='h-6 flex-1 border-0 bg-transparent px-0 text-muted-foreground text-sm leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> +
- + + {/* Blocks Tab Content */} + + +
+ {/* Regular Blocks */} + {regularBlocks.map((block) => ( + + ))} + + {/* Special Blocks (Loop & Parallel) */} + {specialBlocks.map((block) => { + if (block.type === 'loop') { + return + } + if (block.type === 'parallel') { + return ( + + ) + } + return null + })} + + {/* Tools */} + {tools.map((tool) => ( + + ))} +
+
+
+ + {/* Triggers Tab Content */} + + +
+ {triggers.length > 0 ? ( + triggers.map((trigger) => ( + + )) + ) : ( +
+ {searchQuery ? 'No triggers found' : 'Add triggers from the workflow canvas'} +
+ )} +
+
+
+
) } diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index baa26e95c0..14c7f5b683 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -2,6 +2,7 @@ import { AgentIcon } from '@/components/icons' import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import { getAllModelProviders, getBaseModelProviders, @@ -61,6 +62,7 @@ export const AgentBlock: BlockConfig = { type: 'agent', name: 'Agent', description: 'Build an agent', + authMode: AuthMode.ApiKey, longDescription: 'The Agent block is a core workflow block that is a wrapper around an LLM. It takes in system/user prompts and calls an LLM provider. It can also make tool calls by directly containing tools inside of its tool input. It can additionally return structured output.', docsLink: 'https://docs.sim.ai/blocks/agent', diff --git a/apps/sim/blocks/blocks/airtable.ts b/apps/sim/blocks/blocks/airtable.ts index 342297eda9..354ea43fe9 100644 --- a/apps/sim/blocks/blocks/airtable.ts +++ b/apps/sim/blocks/blocks/airtable.ts @@ -1,13 +1,15 @@ import { AirtableIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { AirtableResponse } from '@/tools/airtable/types' export const AirtableBlock: BlockConfig = { type: 'airtable', name: 'Airtable', description: 'Read, create, and update Airtable', + authMode: AuthMode.OAuth, longDescription: - 'Integrates Airtable into the workflow. Can create, get, list, or update Airtable records. Requires OAuth. Can be used in trigger mode to trigger a workflow when an update is made to an Airtable table.', + 'Integrates Airtable into the workflow. Can create, get, list, or update Airtable records. Can be used in trigger mode to trigger a workflow when an update is made to an Airtable table.', docsLink: 'https://docs.sim.ai/tools/airtable', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/api_trigger.ts b/apps/sim/blocks/blocks/api_trigger.ts new file mode 100644 index 0000000000..7451283729 --- /dev/null +++ b/apps/sim/blocks/blocks/api_trigger.ts @@ -0,0 +1,34 @@ +import { ApiIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const ApiTriggerBlock: BlockConfig = { + type: 'api_trigger', + name: 'API', + description: 'Expose as HTTP API endpoint', + longDescription: + 'API trigger to start the workflow via authenticated HTTP calls with structured input.', + category: 'triggers', + bgColor: '#2F55FF', + icon: ApiIcon, + subBlocks: [ + { + id: 'inputFormat', + title: 'Input Format', + type: 'input-format', + layout: 'full', + description: 'Define the JSON input schema accepted by the API endpoint.', + }, + ], + tools: { + access: [], + }, + inputs: {}, + outputs: { + // Dynamic outputs will be added from inputFormat at runtime + // Always includes 'input' field plus any fields defined in inputFormat + }, + triggers: { + enabled: true, + available: ['api'], + }, +} diff --git a/apps/sim/blocks/blocks/browser_use.ts b/apps/sim/blocks/blocks/browser_use.ts index 955125e642..2de35d058a 100644 --- a/apps/sim/blocks/blocks/browser_use.ts +++ b/apps/sim/blocks/blocks/browser_use.ts @@ -1,13 +1,14 @@ import { BrowserUseIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { BrowserUseResponse } from '@/tools/browser_use/types' export const BrowserUseBlock: BlockConfig = { type: 'browser_use', name: 'Browser Use', description: 'Run browser automation tasks', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser. Requires API Key.', + 'Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser.', docsLink: 'https://docs.sim.ai/tools/browser_use', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/chat_trigger.ts b/apps/sim/blocks/blocks/chat_trigger.ts new file mode 100644 index 0000000000..33723562b7 --- /dev/null +++ b/apps/sim/blocks/blocks/chat_trigger.ts @@ -0,0 +1,30 @@ +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { MessageCircle } from 'lucide-react' +import type { BlockConfig } from '@/blocks/types' + +const ChatTriggerIcon = (props: SVGProps) => createElement(MessageCircle, props) + +export const ChatTriggerBlock: BlockConfig = { + type: 'chat_trigger', + name: 'Chat', + description: 'Start workflow from a chat deployment', + longDescription: 'Chat trigger to run the workflow via deployed chat interfaces.', + category: 'triggers', + bgColor: '#6F3DFA', + icon: ChatTriggerIcon, + subBlocks: [], + tools: { + access: [], + }, + inputs: {}, + outputs: { + input: { type: 'string', description: 'User message' }, + conversationId: { type: 'string', description: 'Conversation ID' }, + files: { type: 'array', description: 'Uploaded files' }, + }, + triggers: { + enabled: true, + available: ['chat'], + }, +} diff --git a/apps/sim/blocks/blocks/clay.ts b/apps/sim/blocks/blocks/clay.ts index f52d877718..5b16434943 100644 --- a/apps/sim/blocks/blocks/clay.ts +++ b/apps/sim/blocks/blocks/clay.ts @@ -1,13 +1,13 @@ import { ClayIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ClayPopulateResponse } from '@/tools/clay/types' export const ClayBlock: BlockConfig = { type: 'clay', name: 'Clay', description: 'Populate Clay workbook', - longDescription: - 'Integrate Clay into the workflow. Can populate a table with data. Requires an API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Clay into the workflow. Can populate a table with data.', docsLink: 'https://docs.sim.ai/tools/clay', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/confluence.ts b/apps/sim/blocks/blocks/confluence.ts index e4f4ee0980..9f98983b84 100644 --- a/apps/sim/blocks/blocks/confluence.ts +++ b/apps/sim/blocks/blocks/confluence.ts @@ -1,13 +1,14 @@ import { ConfluenceIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { ConfluenceResponse } from '@/tools/confluence/types' export const ConfluenceBlock: BlockConfig = { type: 'confluence', name: 'Confluence', description: 'Interact with Confluence', - longDescription: - 'Integrate Confluence into the workflow. Can read and update a page. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate Confluence into the workflow. Can read and update a page.', docsLink: 'https://docs.sim.ai/tools/confluence', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts index 432d875b74..edde8bf538 100644 --- a/apps/sim/blocks/blocks/discord.ts +++ b/apps/sim/blocks/blocks/discord.ts @@ -1,13 +1,15 @@ import { DiscordIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { DiscordResponse } from '@/tools/discord/types' export const DiscordBlock: BlockConfig = { type: 'discord', name: 'Discord', description: 'Interact with Discord', + authMode: AuthMode.BotToken, longDescription: - 'Integrate Discord into the workflow. Can send and get messages, get server information, and get a user’s information. Requires bot API key.', + 'Integrate Discord into the workflow. Can send and get messages, get server information, and get a user’s information.', category: 'tools', bgColor: '#E0E0E0', icon: DiscordIcon, diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts index e503d9419b..8e3f1feb36 100644 --- a/apps/sim/blocks/blocks/elevenlabs.ts +++ b/apps/sim/blocks/blocks/elevenlabs.ts @@ -1,13 +1,13 @@ import { ElevenLabsIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ElevenLabsBlockResponse } from '@/tools/elevenlabs/types' export const ElevenLabsBlock: BlockConfig = { type: 'elevenlabs', name: 'ElevenLabs', description: 'Convert TTS using ElevenLabs', - longDescription: - 'Integrate ElevenLabs into the workflow. Can convert text to speech. Requires API key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate ElevenLabs into the workflow. Can convert text to speech.', docsLink: 'https://docs.sim.ai/tools/elevenlabs', category: 'tools', bgColor: '#181C1E', diff --git a/apps/sim/blocks/blocks/exa.ts b/apps/sim/blocks/blocks/exa.ts index 4d939e5cb3..eb39302ce6 100644 --- a/apps/sim/blocks/blocks/exa.ts +++ b/apps/sim/blocks/blocks/exa.ts @@ -1,13 +1,15 @@ import { ExaAIIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { ExaResponse } from '@/tools/exa/types' export const ExaBlock: BlockConfig = { type: 'exa', name: 'Exa', description: 'Search with Exa AI', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research. Requires API Key.', + 'Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research.', docsLink: 'https://docs.sim.ai/tools/exa', category: 'tools', bgColor: '#1F40ED', diff --git a/apps/sim/blocks/blocks/firecrawl.ts b/apps/sim/blocks/blocks/firecrawl.ts index 2cf2947dd2..6487f5d213 100644 --- a/apps/sim/blocks/blocks/firecrawl.ts +++ b/apps/sim/blocks/blocks/firecrawl.ts @@ -1,13 +1,14 @@ import { FirecrawlIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { FirecrawlResponse } from '@/tools/firecrawl/types' export const FirecrawlBlock: BlockConfig = { type: 'firecrawl', name: 'Firecrawl', description: 'Scrape or search the web', - longDescription: - 'Integrate Firecrawl into the workflow. Can search, scrape, or crawl websites. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Firecrawl into the workflow. Can search, scrape, or crawl websites.', docsLink: 'https://docs.sim.ai/tools/firecrawl', category: 'tools', bgColor: '#181C1E', diff --git a/apps/sim/blocks/blocks/generic_webhook.ts b/apps/sim/blocks/blocks/generic_webhook.ts index bd3f668a0c..4e3e8f29ad 100644 --- a/apps/sim/blocks/blocks/generic_webhook.ts +++ b/apps/sim/blocks/blocks/generic_webhook.ts @@ -1,6 +1,10 @@ -import { WebhookIcon } from '@/components/icons' +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Webhook } from 'lucide-react' import type { BlockConfig } from '@/blocks/types' +const WebhookIcon = (props: SVGProps) => createElement(Webhook, props) + export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', name: 'Webhook', @@ -8,6 +12,7 @@ export const GenericWebhookBlock: BlockConfig = { category: 'triggers', icon: WebhookIcon, bgColor: '#10B981', // Green color for triggers + triggerAllowed: true, subBlocks: [ // Generic webhook configuration - always visible diff --git a/apps/sim/blocks/blocks/github.ts b/apps/sim/blocks/blocks/github.ts index c987d52730..963ea91a63 100644 --- a/apps/sim/blocks/blocks/github.ts +++ b/apps/sim/blocks/blocks/github.ts @@ -1,17 +1,20 @@ import { GithubIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GitHubResponse } from '@/tools/github/types' export const GitHubBlock: BlockConfig = { type: 'github', name: 'GitHub', description: 'Interact with GitHub or trigger workflows from GitHub events', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Requires github token API Key. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed.', + 'Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed.', docsLink: 'https://docs.sim.ai/tools/github', category: 'tools', bgColor: '#181C1E', icon: GithubIcon, + triggerAllowed: true, subBlocks: [ { id: 'operation', diff --git a/apps/sim/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index f837bc0c0b..adce423750 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -1,17 +1,20 @@ import { GmailIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GmailToolResponse } from '@/tools/gmail/types' export const GmailBlock: BlockConfig = { type: 'gmail', name: 'Gmail', description: 'Send Gmail or trigger workflows from Gmail events', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Gmail into the workflow. Can send, read, and search emails. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.', + 'Integrate Gmail into the workflow. Can send, read, and search emails. Can be used in trigger mode to trigger a workflow when a new email is received.', docsLink: 'https://docs.sim.ai/tools/gmail', category: 'tools', bgColor: '#E0E0E0', icon: GmailIcon, + triggerAllowed: true, subBlocks: [ // Operation selector { diff --git a/apps/sim/blocks/blocks/google.ts b/apps/sim/blocks/blocks/google.ts index 91f7b4c824..dec8a1fc8e 100644 --- a/apps/sim/blocks/blocks/google.ts +++ b/apps/sim/blocks/blocks/google.ts @@ -1,13 +1,14 @@ import { GoogleIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GoogleSearchResponse } from '@/tools/google/types' export const GoogleSearchBlock: BlockConfig = { type: 'google_search', name: 'Google Search', description: 'Search the web', - longDescription: - 'Integrate Google Search into the workflow. Can search the web. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Google Search into the workflow. Can search the web.', docsLink: 'https://docs.sim.ai/tools/google_search', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/google_calendar.ts b/apps/sim/blocks/blocks/google_calendar.ts index aeaac0bdd8..7f1a22e30d 100644 --- a/apps/sim/blocks/blocks/google_calendar.ts +++ b/apps/sim/blocks/blocks/google_calendar.ts @@ -1,13 +1,15 @@ import { GoogleCalendarIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GoogleCalendarResponse } from '@/tools/google_calendar/types' export const GoogleCalendarBlock: BlockConfig = { type: 'google_calendar', name: 'Google Calendar', description: 'Manage Google Calendar events', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Google Calendar into the workflow. Can create, read, update, and list calendar events. Requires OAuth.', + 'Integrate Google Calendar into the workflow. Can create, read, update, and list calendar events.', docsLink: 'https://docs.sim.ai/tools/google_calendar', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/google_docs.ts b/apps/sim/blocks/blocks/google_docs.ts index 84a4786af7..f023bdfe30 100644 --- a/apps/sim/blocks/blocks/google_docs.ts +++ b/apps/sim/blocks/blocks/google_docs.ts @@ -1,13 +1,15 @@ import { GoogleDocsIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GoogleDocsResponse } from '@/tools/google_docs/types' export const GoogleDocsBlock: BlockConfig = { type: 'google_docs', name: 'Google Docs', description: 'Read, write, and create documents', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Google Docs into the workflow. Can read, write, and create documents. Requires OAuth.', + 'Integrate Google Docs into the workflow. Can read, write, and create documents.', docsLink: 'https://docs.sim.ai/tools/google_docs', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index 0b42940b74..718a9f6644 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -1,13 +1,14 @@ import { GoogleDriveIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GoogleDriveResponse } from '@/tools/google_drive/types' export const GoogleDriveBlock: BlockConfig = { type: 'google_drive', name: 'Google Drive', description: 'Create, upload, and list files', - longDescription: - 'Integrate Google Drive into the workflow. Can create, upload, and list files. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate Google Drive into the workflow. Can create, upload, and list files.', docsLink: 'https://docs.sim.ai/tools/google_drive', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/google_sheets.ts b/apps/sim/blocks/blocks/google_sheets.ts index 3262c4db05..51dff49095 100644 --- a/apps/sim/blocks/blocks/google_sheets.ts +++ b/apps/sim/blocks/blocks/google_sheets.ts @@ -1,13 +1,15 @@ import { GoogleSheetsIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { GoogleSheetsResponse } from '@/tools/google_sheets/types' export const GoogleSheetsBlock: BlockConfig = { type: 'google_sheets', name: 'Google Sheets', description: 'Read, write, and update data', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Google Sheets into the workflow. Can read, write, append, and update data. Requires OAuth.', + 'Integrate Google Sheets into the workflow. Can read, write, append, and update data.', docsLink: 'https://docs.sim.ai/tools/google_sheets', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/huggingface.ts b/apps/sim/blocks/blocks/huggingface.ts index 72ab72281c..5ce7fd2fe4 100644 --- a/apps/sim/blocks/blocks/huggingface.ts +++ b/apps/sim/blocks/blocks/huggingface.ts @@ -1,13 +1,15 @@ import { HuggingFaceIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { HuggingFaceChatResponse } from '@/tools/huggingface/types' export const HuggingFaceBlock: BlockConfig = { type: 'huggingface', name: 'Hugging Face', description: 'Use Hugging Face Inference API', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Hugging Face into the workflow. Can generate completions using the Hugging Face Inference API. Requires API Key.', + 'Integrate Hugging Face into the workflow. Can generate completions using the Hugging Face Inference API.', docsLink: 'https://docs.sim.ai/tools/huggingface', category: 'tools', bgColor: '#0B0F19', diff --git a/apps/sim/blocks/blocks/hunter.ts b/apps/sim/blocks/blocks/hunter.ts index 738222fdbc..8b8f7ca4ad 100644 --- a/apps/sim/blocks/blocks/hunter.ts +++ b/apps/sim/blocks/blocks/hunter.ts @@ -1,13 +1,14 @@ import { HunterIOIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { HunterResponse } from '@/tools/hunter/types' export const HunterBlock: BlockConfig = { type: 'hunter', name: 'Hunter io', description: 'Find and verify professional email addresses', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses. Requires API Key.', + 'Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses.', docsLink: 'https://docs.sim.ai/tools/hunter', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/image_generator.ts b/apps/sim/blocks/blocks/image_generator.ts index 0bb1291898..950d9f21d7 100644 --- a/apps/sim/blocks/blocks/image_generator.ts +++ b/apps/sim/blocks/blocks/image_generator.ts @@ -1,13 +1,14 @@ import { ImageIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { DalleResponse } from '@/tools/openai/types' export const ImageGeneratorBlock: BlockConfig = { type: 'image_generator', name: 'Image Generator', description: 'Generate images', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Image Generator into the workflow. Can generate images using DALL-E 3 or GPT Image. Requires API Key.', + 'Integrate Image Generator into the workflow. Can generate images using DALL-E 3 or GPT Image.', docsLink: 'https://docs.sim.ai/tools/image_generator', category: 'tools', bgColor: '#4D5FFF', diff --git a/apps/sim/blocks/blocks/input_trigger.ts b/apps/sim/blocks/blocks/input_trigger.ts new file mode 100644 index 0000000000..1ada9e07de --- /dev/null +++ b/apps/sim/blocks/blocks/input_trigger.ts @@ -0,0 +1,37 @@ +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Play } from 'lucide-react' +import type { BlockConfig } from '@/blocks/types' + +const InputTriggerIcon = (props: SVGProps) => createElement(Play, props) + +export const InputTriggerBlock: BlockConfig = { + type: 'input_trigger', + name: 'Input Form', + description: 'Start workflow manually with a defined input schema', + longDescription: + 'Manually trigger the workflow from the editor with a structured input schema. This enables typed inputs for parent workflows to map into.', + category: 'triggers', + bgColor: '#3B82F6', + icon: InputTriggerIcon, + subBlocks: [ + { + id: 'inputFormat', + title: 'Input Format', + type: 'input-format', + layout: 'full', + description: 'Define the JSON input schema for this workflow when run manually.', + }, + ], + tools: { + access: [], + }, + inputs: {}, + outputs: { + // Dynamic outputs will be derived from inputFormat + }, + triggers: { + enabled: true, + available: ['manual'], + }, +} diff --git a/apps/sim/blocks/blocks/jina.ts b/apps/sim/blocks/blocks/jina.ts index aa31371f40..7831555eee 100644 --- a/apps/sim/blocks/blocks/jina.ts +++ b/apps/sim/blocks/blocks/jina.ts @@ -1,13 +1,13 @@ import { JinaAIIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ReadUrlResponse } from '@/tools/jina/types' export const JinaBlock: BlockConfig = { type: 'jina', name: 'Jina', description: 'Convert website content into text', - longDescription: - 'Integrate Jina into the workflow. Extracts content from websites. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Jina into the workflow. Extracts content from websites.', docsLink: 'https://docs.sim.ai/tools/jina', category: 'tools', bgColor: '#333333', diff --git a/apps/sim/blocks/blocks/jira.ts b/apps/sim/blocks/blocks/jira.ts index a55e592adc..d59f0683cd 100644 --- a/apps/sim/blocks/blocks/jira.ts +++ b/apps/sim/blocks/blocks/jira.ts @@ -1,13 +1,14 @@ import { JiraIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { JiraResponse } from '@/tools/jira/types' export const JiraBlock: BlockConfig = { type: 'jira', name: 'Jira', description: 'Interact with Jira', - longDescription: - 'Integrate Jira into the workflow. Can read, write, and update issues. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate Jira into the workflow. Can read, write, and update issues.', docsLink: 'https://docs.sim.ai/tools/jira', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/linear.ts b/apps/sim/blocks/blocks/linear.ts index 132842f161..f64c446494 100644 --- a/apps/sim/blocks/blocks/linear.ts +++ b/apps/sim/blocks/blocks/linear.ts @@ -1,5 +1,6 @@ import { LinearIcon } from '@/components/icons' import type { BlockConfig, BlockIcon } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { LinearResponse } from '@/tools/linear/types' const LinearBlockIcon: BlockIcon = (props) => LinearIcon(props as any) @@ -8,8 +9,8 @@ export const LinearBlock: BlockConfig = { type: 'linear', name: 'Linear', description: 'Read and create issues in Linear', - longDescription: - 'Integrate Linear into the workflow. Can read and create issues. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate Linear into the workflow. Can read and create issues.', category: 'tools', icon: LinearBlockIcon, bgColor: '#5E6AD2', diff --git a/apps/sim/blocks/blocks/linkup.ts b/apps/sim/blocks/blocks/linkup.ts index f037e08c06..cad5cf3ca8 100644 --- a/apps/sim/blocks/blocks/linkup.ts +++ b/apps/sim/blocks/blocks/linkup.ts @@ -1,12 +1,13 @@ import { LinkupIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { LinkupSearchToolResponse } from '@/tools/linkup/types' export const LinkupBlock: BlockConfig = { type: 'linkup', name: 'Linkup', description: 'Search the web with Linkup', - longDescription: 'Integrate Linkup into the workflow. Can search the web. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Linkup into the workflow. Can search the web.', docsLink: 'https://docs.sim.ai/tools/linkup', category: 'tools', bgColor: '#D6D3C7', diff --git a/apps/sim/blocks/blocks/mem0.ts b/apps/sim/blocks/blocks/mem0.ts index e2884c69d2..f91884f225 100644 --- a/apps/sim/blocks/blocks/mem0.ts +++ b/apps/sim/blocks/blocks/mem0.ts @@ -1,13 +1,13 @@ import { Mem0Icon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { Mem0Response } from '@/tools/mem0/types' export const Mem0Block: BlockConfig = { type: 'mem0', name: 'Mem0', description: 'Agent memory management', - longDescription: - 'Integrate Mem0 into the workflow. Can add, search, and retrieve memories. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Mem0 into the workflow. Can add, search, and retrieve memories.', bgColor: '#181C1E', icon: Mem0Icon, category: 'tools', diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 7c4f08e2fe..3f9a5ca692 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -1,13 +1,15 @@ import { MicrosoftExcelIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { MicrosoftExcelResponse } from '@/tools/microsoft_excel/types' export const MicrosoftExcelBlock: BlockConfig = { type: 'microsoft_excel', name: 'Microsoft Excel', description: 'Read, write, and update data', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Microsoft Excel into the workflow. Can read, write, update, and add to table. Requires OAuth.', + 'Integrate Microsoft Excel into the workflow. Can read, write, update, and add to table.', docsLink: 'https://docs.sim.ai/tools/microsoft_excel', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/microsoft_planner.ts b/apps/sim/blocks/blocks/microsoft_planner.ts index 3dde715eed..553baf6577 100644 --- a/apps/sim/blocks/blocks/microsoft_planner.ts +++ b/apps/sim/blocks/blocks/microsoft_planner.ts @@ -1,5 +1,6 @@ import { MicrosoftPlannerIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types' interface MicrosoftPlannerBlockParams { @@ -19,8 +20,8 @@ export const MicrosoftPlannerBlock: BlockConfig = { type: 'microsoft_planner', name: 'Microsoft Planner', description: 'Read and create tasks in Microsoft Planner', - longDescription: - 'Integrate Microsoft Planner into the workflow. Can read and create tasks. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate Microsoft Planner into the workflow. Can read and create tasks.', docsLink: 'https://docs.sim.ai/tools/microsoft_planner', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index a19ca9db65..8944a7225a 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -1,15 +1,18 @@ import { MicrosoftTeamsIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { MicrosoftTeamsResponse } from '@/tools/microsoft_teams/types' export const MicrosoftTeamsBlock: BlockConfig = { type: 'microsoft_teams', name: 'Microsoft Teams', description: 'Read, write, and create messages', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel.', + 'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel.', docsLink: 'https://docs.sim.ai/tools/microsoft_teams', category: 'tools', + triggerAllowed: true, bgColor: '#E0E0E0', icon: MicrosoftTeamsIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/mistral_parse.ts b/apps/sim/blocks/blocks/mistral_parse.ts index f9554c6867..c1d7e45d39 100644 --- a/apps/sim/blocks/blocks/mistral_parse.ts +++ b/apps/sim/blocks/blocks/mistral_parse.ts @@ -1,12 +1,13 @@ import { MistralIcon } from '@/components/icons' -import type { BlockConfig, SubBlockLayout, SubBlockType } from '@/blocks/types' +import { AuthMode, type BlockConfig, type SubBlockLayout, type SubBlockType } from '@/blocks/types' import type { MistralParserOutput } from '@/tools/mistral/types' export const MistralParseBlock: BlockConfig = { type: 'mistral_parse', name: 'Mistral Parser', description: 'Extract text from PDF documents', - longDescription: `Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL. Requires API Key.`, + authMode: AuthMode.ApiKey, + longDescription: `Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL.`, docsLink: 'https://docs.sim.ai/tools/mistral_parse', category: 'tools', bgColor: '#000000', diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 1393ca231f..ccfda9114a 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -1,13 +1,15 @@ import { NotionIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { NotionResponse } from '@/tools/notion/types' export const NotionBlock: BlockConfig = { type: 'notion', name: 'Notion', description: 'Manage Notion pages', + authMode: AuthMode.OAuth, longDescription: - 'Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace. Requires OAuth.', + 'Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace.', docsLink: 'https://docs.sim.ai/tools/notion', category: 'tools', bgColor: '#181C1E', diff --git a/apps/sim/blocks/blocks/onedrive.ts b/apps/sim/blocks/blocks/onedrive.ts index ca39032910..512a2a29b1 100644 --- a/apps/sim/blocks/blocks/onedrive.ts +++ b/apps/sim/blocks/blocks/onedrive.ts @@ -1,13 +1,14 @@ import { MicrosoftOneDriveIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { OneDriveResponse } from '@/tools/onedrive/types' export const OneDriveBlock: BlockConfig = { type: 'onedrive', name: 'OneDrive', description: 'Create, upload, and list files', - longDescription: - 'Integrate OneDrive into the workflow. Can create, upload, and list files. Requires OAuth.', + authMode: AuthMode.OAuth, + longDescription: 'Integrate OneDrive into the workflow. Can create, upload, and list files.', docsLink: 'https://docs.sim.ai/tools/onedrive', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/openai.ts b/apps/sim/blocks/blocks/openai.ts index 4df9835640..aac744f0e5 100644 --- a/apps/sim/blocks/blocks/openai.ts +++ b/apps/sim/blocks/blocks/openai.ts @@ -1,12 +1,13 @@ import { OpenAIIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' export const OpenAIBlock: BlockConfig = { type: 'openai', name: 'Embeddings', description: 'Generate Open AI embeddings', - longDescription: - 'Integrate Embeddings into the workflow. Can generate embeddings from text. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Embeddings into the workflow. Can generate embeddings from text.', category: 'tools', docsLink: 'https://docs.sim.ai/tools/openai', bgColor: '#10a37f', diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 91f583baff..27dbb7e628 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -1,15 +1,18 @@ import { OutlookIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { OutlookResponse } from '@/tools/outlook/types' export const OutlookBlock: BlockConfig = { type: 'outlook', name: 'Outlook', description: 'Access Outlook', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Outlook into the workflow. Can read, draft, and send email messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.', + 'Integrate Outlook into the workflow. Can read, draft, and send email messages. Can be used in trigger mode to trigger a workflow when a new email is received.', docsLink: 'https://docs.sim.ai/tools/outlook', category: 'tools', + triggerAllowed: true, bgColor: '#E0E0E0', icon: OutlookIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/parallel.ts b/apps/sim/blocks/blocks/parallel.ts index e6714f25e3..69de17d1d4 100644 --- a/apps/sim/blocks/blocks/parallel.ts +++ b/apps/sim/blocks/blocks/parallel.ts @@ -1,12 +1,13 @@ import { ParallelIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' export const ParallelBlock: BlockConfig = { type: 'parallel_ai', name: 'Parallel AI', description: 'Search with Parallel AI', - longDescription: 'Integrate Parallel AI into the workflow. Can search the web. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Parallel AI into the workflow. Can search the web.', docsLink: 'https://docs.parallel.ai/search-api/search-quickstart', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/perplexity.ts b/apps/sim/blocks/blocks/perplexity.ts index 15801c3340..fecae112be 100644 --- a/apps/sim/blocks/blocks/perplexity.ts +++ b/apps/sim/blocks/blocks/perplexity.ts @@ -1,5 +1,5 @@ import { PerplexityIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { PerplexityChatResponse } from '@/tools/perplexity/types' export const PerplexityBlock: BlockConfig = { @@ -7,7 +7,8 @@ export const PerplexityBlock: BlockConfig = { name: 'Perplexity', description: 'Use Perplexity AI chat models', longDescription: - 'Integrate Perplexity into the workflow. Can generate completions using Perplexity AI chat models. Requires API Key.', + 'Integrate Perplexity into the workflow. Can generate completions using Perplexity AI chat models.', + authMode: AuthMode.ApiKey, docsLink: 'https://docs.sim.ai/tools/perplexity', category: 'tools', bgColor: '#20808D', // Perplexity turquoise color diff --git a/apps/sim/blocks/blocks/pinecone.ts b/apps/sim/blocks/blocks/pinecone.ts index 52c27b9ff1..36a6c5ca57 100644 --- a/apps/sim/blocks/blocks/pinecone.ts +++ b/apps/sim/blocks/blocks/pinecone.ts @@ -1,13 +1,15 @@ import { PineconeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { PineconeResponse } from '@/tools/pinecone/types' export const PineconeBlock: BlockConfig = { type: 'pinecone', name: 'Pinecone', description: 'Use Pinecone vector database', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Pinecone into the workflow. Can generate embeddings, upsert text, search with text, fetch vectors, and search with vectors. Requires API Key.', + 'Integrate Pinecone into the workflow. Can generate embeddings, upsert text, search with text, fetch vectors, and search with vectors.', docsLink: 'https://docs.sim.ai/tools/pinecone', category: 'tools', bgColor: '#0D1117', diff --git a/apps/sim/blocks/blocks/qdrant.ts b/apps/sim/blocks/blocks/qdrant.ts index 9673ce8a3f..a06743764e 100644 --- a/apps/sim/blocks/blocks/qdrant.ts +++ b/apps/sim/blocks/blocks/qdrant.ts @@ -1,13 +1,14 @@ import { QdrantIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { QdrantResponse } from '@/tools/qdrant/types' export const QdrantBlock: BlockConfig = { type: 'qdrant', name: 'Qdrant', description: 'Use Qdrant vector database', - longDescription: - 'Integrate Qdrant into the workflow. Can upsert, search, and fetch points. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Qdrant into the workflow. Can upsert, search, and fetch points.', docsLink: 'https://qdrant.tech/documentation/', category: 'tools', bgColor: '#1A223F', diff --git a/apps/sim/blocks/blocks/reddit.ts b/apps/sim/blocks/blocks/reddit.ts index 102e803a24..ffbe0509e2 100644 --- a/apps/sim/blocks/blocks/reddit.ts +++ b/apps/sim/blocks/blocks/reddit.ts @@ -1,13 +1,15 @@ import { RedditIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { RedditResponse } from '@/tools/reddit/types' export const RedditBlock: BlockConfig = { type: 'reddit', name: 'Reddit', description: 'Access Reddit data and content', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Reddit into the workflow. Can get posts and comments from a subreddit. Requires OAuth.', + 'Integrate Reddit into the workflow. Can get posts and comments from a subreddit.', docsLink: 'https://docs.sim.ai/tools/reddit', category: 'tools', bgColor: '#FF5700', diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index b84f722a59..6c909650b3 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -1,6 +1,6 @@ import { ConnectIcon } from '@/components/icons' import { isHosted } from '@/lib/environment' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ProviderId } from '@/providers/types' import { getAllModelProviders, @@ -108,6 +108,7 @@ export const RouterBlock: BlockConfig = { type: 'router', name: 'Router', description: 'Route workflow', + authMode: AuthMode.ApiKey, longDescription: 'This is a core workflow block. Intelligently direct workflow execution to different paths based on input analysis. Use natural language to instruct the router to route to certain blocks based on the input.', category: 'blocks', diff --git a/apps/sim/blocks/blocks/s3.ts b/apps/sim/blocks/blocks/s3.ts index c29dbabfcb..577c6cc374 100644 --- a/apps/sim/blocks/blocks/s3.ts +++ b/apps/sim/blocks/blocks/s3.ts @@ -1,11 +1,13 @@ import { S3Icon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { S3Response } from '@/tools/s3/types' export const S3Block: BlockConfig = { type: 's3', name: 'S3', description: 'View S3 files', + authMode: AuthMode.ApiKey, longDescription: 'Integrate S3 into the workflow. Can get presigned URLs for S3 objects. Requires access key and secret access key.', docsLink: 'https://docs.sim.ai/tools/s3', diff --git a/apps/sim/blocks/blocks/schedule.ts b/apps/sim/blocks/blocks/schedule.ts index b9e7ddafc4..ff3eefaf57 100644 --- a/apps/sim/blocks/blocks/schedule.ts +++ b/apps/sim/blocks/blocks/schedule.ts @@ -1,6 +1,10 @@ -import { ScheduleIcon } from '@/components/icons' +import type { SVGProps } from 'react' +import { createElement } from 'react' +import { Clock } from 'lucide-react' import type { BlockConfig } from '@/blocks/types' +const ScheduleIcon = (props: SVGProps) => createElement(Clock, props) + export const ScheduleBlock: BlockConfig = { type: 'schedule', name: 'Schedule', @@ -8,7 +12,7 @@ export const ScheduleBlock: BlockConfig = { longDescription: 'Integrate Schedule into the workflow. Can trigger a workflow on a schedule configuration.', category: 'triggers', - bgColor: '#7B68EE', + bgColor: '#6366F1', icon: ScheduleIcon, subBlocks: [ diff --git a/apps/sim/blocks/blocks/serper.ts b/apps/sim/blocks/blocks/serper.ts index f98ebe4a57..267adafbf4 100644 --- a/apps/sim/blocks/blocks/serper.ts +++ b/apps/sim/blocks/blocks/serper.ts @@ -1,12 +1,14 @@ import { SerperIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { SearchResponse } from '@/tools/serper/types' export const SerperBlock: BlockConfig = { type: 'serper', name: 'Serper', description: 'Search the web using Serper', - longDescription: 'Integrate Serper into the workflow. Can search the web. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Serper into the workflow. Can search the web.', docsLink: 'https://docs.sim.ai/tools/serper', category: 'tools', bgColor: '#2B3543', diff --git a/apps/sim/blocks/blocks/sharepoint.ts b/apps/sim/blocks/blocks/sharepoint.ts index e7878cc117..54af923978 100644 --- a/apps/sim/blocks/blocks/sharepoint.ts +++ b/apps/sim/blocks/blocks/sharepoint.ts @@ -1,6 +1,7 @@ import { MicrosoftSharepointIcon } from '@/components/icons' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { SharepointResponse } from '@/tools/sharepoint/types' const logger = createLogger('SharepointBlock') @@ -9,6 +10,7 @@ export const SharepointBlock: BlockConfig = { type: 'sharepoint', name: 'Sharepoint', description: 'Work with pages and lists', + authMode: AuthMode.OAuth, longDescription: 'Integrate SharePoint into the workflow. Read/create pages, list sites, and work with lists (read, create, update items). Requires OAuth.', docsLink: 'https://docs.sim.ai/tools/sharepoint', diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 89cab78483..305bb32c29 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,17 +1,20 @@ import { SlackIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { SlackResponse } from '@/tools/slack/types' export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', description: 'Send messages to Slack or trigger workflows from Slack events', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Slack into the workflow. Can send messages, create canvases, and read messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.', + 'Integrate Slack into the workflow. Can send messages, create canvases, and read messages. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.', docsLink: 'https://docs.sim.ai/tools/slack', category: 'tools', bgColor: '#611f69', icon: SlackIcon, + triggerAllowed: true, subBlocks: [ { id: 'operation', diff --git a/apps/sim/blocks/blocks/stagehand.ts b/apps/sim/blocks/blocks/stagehand.ts index ac23277833..6ef63cbf69 100644 --- a/apps/sim/blocks/blocks/stagehand.ts +++ b/apps/sim/blocks/blocks/stagehand.ts @@ -1,5 +1,5 @@ import { StagehandIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' export interface StagehandExtractResponse extends ToolResponse { @@ -12,8 +12,9 @@ export const StagehandBlock: BlockConfig = { type: 'stagehand', name: 'Stagehand Extract', description: 'Extract data from websites', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Stagehand into the workflow. Can extract structured data from webpages. Requires API Key.', + 'Integrate Stagehand into the workflow. Can extract structured data from webpages.', docsLink: 'https://docs.sim.ai/tools/stagehand', category: 'tools', bgColor: '#FFC83C', diff --git a/apps/sim/blocks/blocks/stagehand_agent.ts b/apps/sim/blocks/blocks/stagehand_agent.ts index c2dc79cc65..f6225bb847 100644 --- a/apps/sim/blocks/blocks/stagehand_agent.ts +++ b/apps/sim/blocks/blocks/stagehand_agent.ts @@ -1,13 +1,14 @@ import { StagehandIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { StagehandAgentResponse } from '@/tools/stagehand/types' export const StagehandAgentBlock: BlockConfig = { type: 'stagehand_agent', name: 'Stagehand Agent', description: 'Autonomous web browsing agent', + authMode: AuthMode.ApiKey, longDescription: - 'Integrate Stagehand Agent into the workflow. Can navigate the web and perform tasks. Requires API Key.', + 'Integrate Stagehand Agent into the workflow. Can navigate the web and perform tasks.', docsLink: 'https://docs.sim.ai/tools/stagehand_agent', category: 'tools', bgColor: '#FFC83C', diff --git a/apps/sim/blocks/blocks/starter.ts b/apps/sim/blocks/blocks/starter.ts index 7a9ed26d4f..68efe3c013 100644 --- a/apps/sim/blocks/blocks/starter.ts +++ b/apps/sim/blocks/blocks/starter.ts @@ -9,6 +9,7 @@ export const StarterBlock: BlockConfig = { category: 'blocks', bgColor: '#2FB3FF', icon: StartIcon, + hideFromToolbar: true, subBlocks: [ // Main trigger selector { diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 09b6f01bb4..2bd6c57d95 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -1,6 +1,6 @@ import { SupabaseIcon } from '@/components/icons' import { createLogger } from '@/lib/logs/console/logger' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import type { SupabaseResponse } from '@/tools/supabase/types' const logger = createLogger('SupabaseBlock') @@ -9,6 +9,7 @@ export const SupabaseBlock: BlockConfig = { type: 'supabase', name: 'Supabase', description: 'Use Supabase database', + authMode: AuthMode.ApiKey, longDescription: 'Integrate Supabase into the workflow. Can get many rows, get, create, update, delete, and upsert a row.', docsLink: 'https://docs.sim.ai/tools/supabase', diff --git a/apps/sim/blocks/blocks/tavily.ts b/apps/sim/blocks/blocks/tavily.ts index 5e8196c867..a75013df9f 100644 --- a/apps/sim/blocks/blocks/tavily.ts +++ b/apps/sim/blocks/blocks/tavily.ts @@ -1,11 +1,13 @@ import { TavilyIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { TavilyResponse } from '@/tools/tavily/types' export const TavilyBlock: BlockConfig = { type: 'tavily', name: 'Tavily', description: 'Search and extract information', + authMode: AuthMode.ApiKey, longDescription: 'Integrate Tavily into the workflow. Can search the web and extract content from specific URLs. Requires API Key.', category: 'tools', diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 66d5b8dde1..3f247c525a 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -1,17 +1,20 @@ import { TelegramIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { TelegramMessageResponse } from '@/tools/telegram/types' export const TelegramBlock: BlockConfig = { type: 'telegram', name: 'Telegram', description: 'Send messages through Telegram or trigger workflows from Telegram events', + authMode: AuthMode.BotToken, longDescription: 'Integrate Telegram into the workflow. Can send messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.', docsLink: 'https://docs.sim.ai/tools/telegram', category: 'tools', bgColor: '#E0E0E0', icon: TelegramIcon, + triggerAllowed: true, subBlocks: [ { id: 'botToken', diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index 360221edeb..a4e4f5c199 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -1,6 +1,6 @@ import { TranslateIcon } from '@/components/icons' import { isHosted } from '@/lib/environment' -import type { BlockConfig } from '@/blocks/types' +import { AuthMode, type BlockConfig } from '@/blocks/types' import { getAllModelProviders, getBaseModelProviders, @@ -29,6 +29,7 @@ export const TranslateBlock: BlockConfig = { type: 'translate', name: 'Translate', description: 'Translate text to any language', + authMode: AuthMode.ApiKey, longDescription: 'Integrate Translate into the workflow. Can translate text to any language.', docsLink: 'https://docs.sim.ai/tools/translate', category: 'tools', diff --git a/apps/sim/blocks/blocks/twilio.ts b/apps/sim/blocks/blocks/twilio.ts index 6a51b901f2..f997f0dd86 100644 --- a/apps/sim/blocks/blocks/twilio.ts +++ b/apps/sim/blocks/blocks/twilio.ts @@ -1,11 +1,13 @@ import { TwilioIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { TwilioSMSBlockOutput } from '@/tools/twilio/types' export const TwilioSMSBlock: BlockConfig = { type: 'twilio_sms', name: 'Twilio SMS', description: 'Send SMS messages', + authMode: AuthMode.ApiKey, longDescription: 'Integrate Twilio into the workflow. Can send SMS messages.', category: 'tools', bgColor: '#F22F46', // Twilio brand color diff --git a/apps/sim/blocks/blocks/typeform.ts b/apps/sim/blocks/blocks/typeform.ts index f98f1a87fd..c63334e71e 100644 --- a/apps/sim/blocks/blocks/typeform.ts +++ b/apps/sim/blocks/blocks/typeform.ts @@ -1,11 +1,13 @@ import { TypeformIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { TypeformResponse } from '@/tools/typeform/types' export const TypeformBlock: BlockConfig = { type: 'typeform', name: 'Typeform', description: 'Interact with Typeform', + authMode: AuthMode.ApiKey, longDescription: 'Integrate Typeform into the workflow. Can retrieve responses, download files, and get form insights. Requires API Key.', docsLink: 'https://docs.sim.ai/tools/typeform', diff --git a/apps/sim/blocks/blocks/vision.ts b/apps/sim/blocks/blocks/vision.ts index dfab793e1e..5d56ad1b1d 100644 --- a/apps/sim/blocks/blocks/vision.ts +++ b/apps/sim/blocks/blocks/vision.ts @@ -1,13 +1,14 @@ import { EyeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { VisionResponse } from '@/tools/vision/types' export const VisionBlock: BlockConfig = { type: 'vision', name: 'Vision', description: 'Analyze images with vision models', - longDescription: - 'Integrate Vision into the workflow. Can analyze images with vision models. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate Vision into the workflow. Can analyze images with vision models.', docsLink: 'https://docs.sim.ai/tools/vision', category: 'tools', bgColor: '#4D5FFF', diff --git a/apps/sim/blocks/blocks/wealthbox.ts b/apps/sim/blocks/blocks/wealthbox.ts index 7e0e59c6e7..7022cc8342 100644 --- a/apps/sim/blocks/blocks/wealthbox.ts +++ b/apps/sim/blocks/blocks/wealthbox.ts @@ -1,13 +1,15 @@ import { WealthboxIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { WealthboxResponse } from '@/tools/wealthbox/types' export const WealthboxBlock: BlockConfig = { type: 'wealthbox', name: 'Wealthbox', description: 'Interact with Wealthbox', + authMode: AuthMode.OAuth, longDescription: - 'Integrate Wealthbox into the workflow. Can read and write notes, read and write contacts, and read and write tasks. Requires OAuth.', + 'Integrate Wealthbox into the workflow. Can read and write notes, read and write contacts, and read and write tasks.', docsLink: 'https://docs.sim.ai/tools/wealthbox', category: 'tools', bgColor: '#E0E0E0', diff --git a/apps/sim/blocks/blocks/webhook.ts b/apps/sim/blocks/blocks/webhook.ts index d5b870fa00..27874f33a2 100644 --- a/apps/sim/blocks/blocks/webhook.ts +++ b/apps/sim/blocks/blocks/webhook.ts @@ -13,6 +13,7 @@ import { WhatsAppIcon, } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' const getWebhookProviderIcon = (provider: string) => { const iconMap: Record> = { @@ -36,9 +37,11 @@ export const WebhookBlock: BlockConfig = { type: 'webhook', name: 'Webhook', description: 'Trigger workflow execution from external webhooks', + authMode: AuthMode.OAuth, category: 'triggers', icon: WebhookIcon, bgColor: '#10B981', // Green color for triggers + triggerAllowed: true, hideFromToolbar: true, // Hidden for backwards compatibility - use generic webhook trigger instead subBlocks: [ diff --git a/apps/sim/blocks/blocks/whatsapp.ts b/apps/sim/blocks/blocks/whatsapp.ts index 4899f3e5ad..77801d1ad8 100644 --- a/apps/sim/blocks/blocks/whatsapp.ts +++ b/apps/sim/blocks/blocks/whatsapp.ts @@ -1,16 +1,19 @@ import { WhatsAppIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { WhatsAppResponse } from '@/tools/whatsapp/types' export const WhatsAppBlock: BlockConfig = { type: 'whatsapp', name: 'WhatsApp', description: 'Send WhatsApp messages', + authMode: AuthMode.ApiKey, longDescription: 'Integrate WhatsApp into the workflow. Can send messages.', docsLink: 'https://docs.sim.ai/tools/whatsapp', category: 'tools', bgColor: '#25D366', icon: WhatsAppIcon, + triggerAllowed: true, subBlocks: [ { id: 'phoneNumber', diff --git a/apps/sim/blocks/blocks/workflow.ts b/apps/sim/blocks/blocks/workflow.ts index 69eda686af..734913dbb1 100644 --- a/apps/sim/blocks/blocks/workflow.ts +++ b/apps/sim/blocks/blocks/workflow.ts @@ -80,4 +80,5 @@ export const WorkflowBlock: BlockConfig = { result: { type: 'json', description: 'Workflow execution result' }, error: { type: 'string', description: 'Error message' }, }, + hideFromToolbar: true, } diff --git a/apps/sim/blocks/blocks/workflow_input.ts b/apps/sim/blocks/blocks/workflow_input.ts new file mode 100644 index 0000000000..a8c9d313cf --- /dev/null +++ b/apps/sim/blocks/blocks/workflow_input.ts @@ -0,0 +1,58 @@ +import { WorkflowIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +// Helper: list workflows excluding self +const getAvailableWorkflows = (): Array<{ label: string; id: string }> => { + try { + const { workflows, activeWorkflowId } = useWorkflowRegistry.getState() + return Object.entries(workflows) + .filter(([id]) => id !== activeWorkflowId) + .map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id })) + .sort((a, b) => a.label.localeCompare(b.label)) + } catch { + return [] + } +} + +// New workflow block variant that visualizes child Input Trigger schema for mapping +export const WorkflowInputBlock: BlockConfig = { + type: 'workflow_input', + name: 'Workflow', + description: 'Execute another workflow and map variables to its Input Trigger schema.', + category: 'blocks', + bgColor: '#6366F1', // Indigo - modern and professional + icon: WorkflowIcon, + subBlocks: [ + { + id: 'workflowId', + title: 'Select Workflow', + type: 'dropdown', + options: getAvailableWorkflows, + required: true, + }, + // Renders dynamic mapping UI based on selected child workflow's Input Trigger inputFormat + { + id: 'inputMapping', + title: 'Input Mapping', + type: 'input-mapping', + layout: 'full', + description: + "Map fields defined in the child workflow's Input Trigger to variables/values in this workflow.", + dependsOn: ['workflowId'], + }, + ], + tools: { + access: ['workflow_executor'], + }, + inputs: { + workflowId: { type: 'string', description: 'ID of the child workflow' }, + inputMapping: { type: 'json', description: 'Mapping of input fields to values' }, + }, + outputs: { + success: { type: 'boolean', description: 'Execution success status' }, + childWorkflowName: { type: 'string', description: 'Child workflow name' }, + result: { type: 'json', description: 'Workflow execution result' }, + error: { type: 'string', description: 'Error message' }, + }, +} diff --git a/apps/sim/blocks/blocks/x.ts b/apps/sim/blocks/blocks/x.ts index a47fa65031..2b675925fe 100644 --- a/apps/sim/blocks/blocks/x.ts +++ b/apps/sim/blocks/blocks/x.ts @@ -1,13 +1,15 @@ import { xIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { XResponse } from '@/tools/x/types' export const XBlock: BlockConfig = { type: 'x', name: 'X', description: 'Interact with X', + authMode: AuthMode.OAuth, longDescription: - 'Integrate X into the workflow. Can post a new tweet, get tweet details, search tweets, and get user profile. Requires OAuth.', + 'Integrate X into the workflow. Can post a new tweet, get tweet details, search tweets, and get user profile.', docsLink: 'https://docs.sim.ai/tools/x', category: 'tools', bgColor: '#000000', // X's black color diff --git a/apps/sim/blocks/blocks/youtube.ts b/apps/sim/blocks/blocks/youtube.ts index b5c53deafa..982f924c0b 100644 --- a/apps/sim/blocks/blocks/youtube.ts +++ b/apps/sim/blocks/blocks/youtube.ts @@ -1,16 +1,20 @@ import { YouTubeIcon } from '@/components/icons' -import type { BlockConfig } from '@/blocks/types' +import type { BlockConfig, BlockIcon } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import type { YouTubeSearchResponse } from '@/tools/youtube/types' +const YouTubeBlockIcon: BlockIcon = (props) => YouTubeIcon(props as any) + export const YouTubeBlock: BlockConfig = { type: 'youtube', name: 'YouTube', description: 'Search for videos on YouTube', - longDescription: 'Integrate YouTube into the workflow. Can search for videos. Requires API Key.', + authMode: AuthMode.ApiKey, + longDescription: 'Integrate YouTube into the workflow. Can search for videos.', docsLink: 'https://docs.sim.ai/tools/youtube', category: 'tools', bgColor: '#FF0000', - icon: YouTubeIcon, + icon: YouTubeBlockIcon, subBlocks: [ { id: 'query', diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index adef4e6e22..0f5510694d 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -6,8 +6,10 @@ import { AgentBlock } from '@/blocks/blocks/agent' import { AirtableBlock } from '@/blocks/blocks/airtable' import { ApiBlock } from '@/blocks/blocks/api' +import { ApiTriggerBlock } from '@/blocks/blocks/api_trigger' import { ArxivBlock } from '@/blocks/blocks/arxiv' import { BrowserUseBlock } from '@/blocks/blocks/browser_use' +import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { ClayBlock } from '@/blocks/blocks/clay' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock } from '@/blocks/blocks/confluence' @@ -30,6 +32,7 @@ import { GoogleSheetsBlock } from '@/blocks/blocks/google_sheets' import { HuggingFaceBlock } from '@/blocks/blocks/huggingface' import { HunterBlock } from '@/blocks/blocks/hunter' import { ImageGeneratorBlock } from '@/blocks/blocks/image_generator' +import { InputTriggerBlock } from '@/blocks/blocks/input_trigger' import { JinaBlock } from '@/blocks/blocks/jina' import { JiraBlock } from '@/blocks/blocks/jira' import { KnowledgeBlock } from '@/blocks/blocks/knowledge' @@ -78,6 +81,7 @@ import { WebhookBlock } from '@/blocks/blocks/webhook' import { WhatsAppBlock } from '@/blocks/blocks/whatsapp' import { WikipediaBlock } from '@/blocks/blocks/wikipedia' import { WorkflowBlock } from '@/blocks/blocks/workflow' +import { WorkflowInputBlock } from '@/blocks/blocks/workflow_input' import { XBlock } from '@/blocks/blocks/x' import { YouTubeBlock } from '@/blocks/blocks/youtube' import type { BlockConfig } from '@/blocks/types' @@ -147,6 +151,9 @@ export const registry: Record = { stagehand_agent: StagehandAgentBlock, slack: SlackBlock, starter: StarterBlock, + input_trigger: InputTriggerBlock, + chat_trigger: ChatTriggerBlock, + api_trigger: ApiTriggerBlock, supabase: SupabaseBlock, tavily: TavilyBlock, telegram: TelegramBlock, @@ -160,6 +167,7 @@ export const registry: Record = { whatsapp: WhatsAppBlock, wikipedia: WikipediaBlock, workflow: WorkflowBlock, + workflow_input: WorkflowInputBlock, x: XBlock, youtube: YouTubeBlock, } diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 55158b6d89..0ebf408f84 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -7,6 +7,13 @@ export type PrimitiveValueType = 'string' | 'number' | 'boolean' | 'json' | 'arr export type BlockCategory = 'blocks' | 'tools' | 'triggers' +// Authentication modes for sub-blocks and summaries +export enum AuthMode { + OAuth = 'oauth', + ApiKey = 'api_key', + BotToken = 'bot_token', +} + export type GenerationType = | 'javascript-function-body' | 'typescript-function-body' @@ -54,6 +61,7 @@ export type SubBlockType = | 'input-format' // Input structure format | 'response-format' // Response structure format | 'file-upload' // File uploader + | 'input-mapping' // Map parent variables to child workflow input schema export type SubBlockLayout = 'full' | 'half' @@ -186,6 +194,8 @@ export interface BlockConfig { bgColor: string icon: BlockIcon subBlocks: SubBlockConfig[] + triggerAllowed?: boolean + authMode?: AuthMode tools: { access: string[] config?: { diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 1ee1067d01..717ff4facf 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -4,6 +4,7 @@ import { ChevronRight } from 'lucide-react' import { BlockPathCalculator } from '@/lib/block-path-calculator' import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' import { cn } from '@/lib/utils' +import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs' import { getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' import { Serializer } from '@/serializer' @@ -101,7 +102,8 @@ const getOutputTypeForPath = ( block: BlockState, blockConfig: BlockConfig | null, blockId: string, - outputPath: string + outputPath: string, + mergedSubBlocksOverride?: Record ): string => { if (block?.triggerMode && blockConfig?.triggers?.enabled) { const triggerId = blockConfig?.triggers?.available?.[0] @@ -125,7 +127,8 @@ const getOutputTypeForPath = ( } } else if (block?.type === 'starter') { // Handle starter block specific outputs - const startWorkflowValue = getSubBlockValue(blockId, 'startWorkflow') + const startWorkflowValue = + mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow') if (startWorkflowValue === 'chat') { // Define types for chat mode outputs @@ -137,7 +140,8 @@ const getOutputTypeForPath = ( return chatModeTypes[outputPath] || 'any' } // For API mode, check inputFormat for custom field types - const inputFormatValue = getSubBlockValue(blockId, 'inputFormat') + const inputFormatValue = + mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat') if (inputFormatValue && Array.isArray(inputFormatValue)) { const field = inputFormatValue.find( (f: { name?: string; type?: string }) => f.name === outputPath @@ -146,6 +150,11 @@ const getOutputTypeForPath = ( return field.type } } + } else if (blockConfig?.category === 'triggers') { + // For trigger blocks, use the dynamic output helper + const blockState = useWorkflowStore.getState().blocks[blockId] + const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {}) + return getBlockOutputType(block.type, outputPath, subBlocks) } else { const operationValue = getSubBlockValue(blockId, 'operation') if (blockConfig && operationValue) { @@ -297,6 +306,24 @@ export const TagDropdown: React.FC = ({ const edges = useWorkflowStore((state) => state.edges) const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + // Subscribe to live subblock values for the active workflow to react to input format changes + const workflowSubBlockValues = useSubBlockStore((state) => + workflowId ? (state.workflowValues[workflowId] ?? {}) : {} + ) + + const getMergedSubBlocks = useCallback( + (targetBlockId: string): Record => { + const base = blocks[targetBlockId]?.subBlocks || {} + const live = workflowSubBlockValues?.[targetBlockId] || {} + const merged: Record = { ...base } + for (const [subId, liveVal] of Object.entries(live)) { + merged[subId] = { ...(base[subId] || {}), value: liveVal } + } + return merged + }, + [blocks, workflowSubBlockValues] + ) + const getVariablesByWorkflowId = useVariablesStore((state) => state.getVariablesByWorkflowId) const variables = useVariablesStore((state) => state.variables) const workflowVariables = workflowId ? getVariablesByWorkflowId(workflowId) : [] @@ -355,7 +382,8 @@ export const TagDropdown: React.FC = ({ const blockName = sourceBlock.name || sourceBlock.type const normalizedBlockName = normalizeBlockName(blockName) - const responseFormatValue = getSubBlockValue(activeSourceBlockId, 'responseFormat') + const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId) + const responseFormatValue = mergedSubBlocks?.responseFormat?.value const responseFormat = parseResponseFormatSafely(responseFormatValue, activeSourceBlockId) let blockTags: string[] @@ -382,7 +410,7 @@ export const TagDropdown: React.FC = ({ } } else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) { if (sourceBlock.type === 'starter') { - const startWorkflowValue = getSubBlockValue(activeSourceBlockId, 'startWorkflow') + const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value if (startWorkflowValue === 'chat') { // For chat mode, provide input, conversationId, and files @@ -392,7 +420,7 @@ export const TagDropdown: React.FC = ({ `${normalizedBlockName}.files`, ] } else { - const inputFormatValue = getSubBlockValue(activeSourceBlockId, 'inputFormat') + const inputFormatValue = mergedSubBlocks?.inputFormat?.value if ( inputFormatValue && @@ -410,7 +438,17 @@ export const TagDropdown: React.FC = ({ blockTags = [normalizedBlockName] } } else { - if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) { + // For triggers and starter blocks, use dynamic outputs based on live subblock values + if (blockConfig.category === 'triggers' || sourceBlock.type === 'starter') { + const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks) + if (dynamicOutputs.length > 0) { + blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) + } else if (sourceBlock.type === 'starter') { + blockTags = [normalizedBlockName] + } else { + blockTags = [] + } + } else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) { const triggerId = blockConfig?.triggers?.available?.[0] const firstTrigger = triggerId ? getTrigger(triggerId) @@ -426,7 +464,8 @@ export const TagDropdown: React.FC = ({ } } else { // Check for tool-specific outputs first - const operationValue = getSubBlockValue(activeSourceBlockId, 'operation') + const operationValue = + mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') const toolOutputPaths = operationValue ? generateToolOutputPaths(blockConfig, operationValue) : [] @@ -625,12 +664,34 @@ export const TagDropdown: React.FC = ({ const blockName = accessibleBlock.name || accessibleBlock.type const normalizedBlockName = normalizeBlockName(blockName) - const responseFormatValue = getSubBlockValue(accessibleBlockId, 'responseFormat') + const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId) + const responseFormatValue = mergedSubBlocks?.responseFormat?.value const responseFormat = parseResponseFormatSafely(responseFormatValue, accessibleBlockId) let blockTags: string[] - if (accessibleBlock.type === 'evaluator') { + // For trigger blocks, use the dynamic output helper + if (blockConfig.category === 'triggers' || accessibleBlock.type === 'starter') { + const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks) + + if (dynamicOutputs.length > 0) { + blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) + } else if (accessibleBlock.type === 'starter') { + // Legacy starter block fallback + const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value + if (startWorkflowValue === 'chat') { + blockTags = [ + `${normalizedBlockName}.input`, + `${normalizedBlockName}.conversationId`, + `${normalizedBlockName}.files`, + ] + } else { + blockTags = [normalizedBlockName] + } + } else { + blockTags = [] + } + } else if (accessibleBlock.type === 'evaluator') { const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics') if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) { @@ -651,34 +712,7 @@ export const TagDropdown: React.FC = ({ blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) } } else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) { - if (accessibleBlock.type === 'starter') { - const startWorkflowValue = getSubBlockValue(accessibleBlockId, 'startWorkflow') - - if (startWorkflowValue === 'chat') { - // For chat mode, provide input, conversationId, and files - blockTags = [ - `${normalizedBlockName}.input`, - `${normalizedBlockName}.conversationId`, - `${normalizedBlockName}.files`, - ] - } else { - const inputFormatValue = getSubBlockValue(accessibleBlockId, 'inputFormat') - - if ( - inputFormatValue && - Array.isArray(inputFormatValue) && - inputFormatValue.length > 0 - ) { - blockTags = inputFormatValue - .filter((field: { name?: string }) => field.name && field.name.trim() !== '') - .map((field: { name: string }) => `${normalizedBlockName}.${field.name}`) - } else { - blockTags = [normalizedBlockName] - } - } - } else { - blockTags = [normalizedBlockName] - } + blockTags = [normalizedBlockName] } else { const blockState = blocks[accessibleBlockId] if (blockState?.triggerMode && blockConfig.triggers?.enabled) { @@ -697,7 +731,8 @@ export const TagDropdown: React.FC = ({ } } else { // Check for tool-specific outputs first - const operationValue = getSubBlockValue(accessibleBlockId, 'operation') + const operationValue = + mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') const toolOutputPaths = operationValue ? generateToolOutputPaths(blockConfig, operationValue) : [] @@ -746,7 +781,17 @@ export const TagDropdown: React.FC = ({ variableInfoMap, blockTagGroups: finalBlockTagGroups, } - }, [blocks, edges, loops, parallels, blockId, activeSourceBlockId, workflowVariables]) + }, [ + blocks, + edges, + loops, + parallels, + blockId, + activeSourceBlockId, + workflowVariables, + workflowSubBlockValues, + getMergedSubBlocks, + ]) const filteredTags = useMemo(() => { if (!searchTerm) return tags @@ -1328,12 +1373,14 @@ export const TagDropdown: React.FC = ({ ) if (block) { const blockConfig = getBlock(block.type) + const mergedSubBlocks = getMergedSubBlocks(group.blockId) tagDescription = getOutputTypeForPath( block, blockConfig || null, group.blockId, - outputPath + outputPath, + mergedSubBlocks ) } } @@ -1468,12 +1515,14 @@ export const TagDropdown: React.FC = ({ ) if (block) { const blockConfig = getBlock(block.type) + const mergedSubBlocks = getMergedSubBlocks(group.blockId) childType = getOutputTypeForPath( block, blockConfig || null, group.blockId, - childOutputPath + childOutputPath, + mergedSubBlocks ) } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index fc3603417d..d4c8e311bf 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -22,8 +22,20 @@ const MAX_WORKFLOW_DEPTH = 10 export class WorkflowBlockHandler implements BlockHandler { private serializer = new Serializer() + // Tolerant JSON parser for mapping values + // Keeps handler self-contained without introducing utilities + private safeParse(input: unknown): unknown { + if (typeof input !== 'string') return input + try { + return JSON.parse(input) + } catch { + return input + } + } + canHandle(block: SerializedBlock): boolean { - return block.metadata?.id === BlockType.WORKFLOW + const id = block.metadata?.id + return id === BlockType.WORKFLOW || id === 'workflow_input' } async execute( @@ -63,13 +75,22 @@ export class WorkflowBlockHandler implements BlockHandler { ) // Prepare the input for the child workflow - // The input from this block should be passed as start.input to the child workflow - let childWorkflowInput = {} - - if (inputs.input !== undefined) { - // If input is provided, use it directly + // Prefer structured mapping if provided; otherwise fall back to legacy 'input' passthrough + let childWorkflowInput: Record = {} + + if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) { + // Handle inputMapping - could be object or stringified JSON + const raw = inputs.inputMapping + const normalized = this.safeParse(raw) + + if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + childWorkflowInput = normalized as Record + } else { + childWorkflowInput = {} + } + } else if (inputs.input !== undefined) { + // Legacy behavior: pass under start.input childWorkflowInput = inputs.input - logger.info(`Passing input to child workflow: ${JSON.stringify(childWorkflowInput)}`) } // Remove the workflowId from the input to avoid confusion @@ -308,10 +329,11 @@ export class WorkflowBlockHandler implements BlockHandler { } return failure as Record } - let result = childResult - if (childResult?.output) { - result = childResult.output - } + + // childResult is an ExecutionResult with structure { success, output, metadata, logs } + // We want the actual output from the execution + const result = childResult.output || {} + return { success: true, childWorkflowName, diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index c2777d3542..324a05efb9 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -630,17 +630,31 @@ export class Executor { */ private validateWorkflow(startBlockId?: string): void { if (startBlockId) { - // If starting from a specific block (webhook trigger or schedule trigger), validate that block exists const startBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) if (!startBlock || !startBlock.enabled) { throw new Error(`Start block ${startBlockId} not found or disabled`) } - // Trigger blocks (webhook and schedule) can have incoming connections, so no need to check that + return + } + + const starterBlock = this.actualWorkflow.blocks.find( + (block) => block.metadata?.id === BlockType.STARTER + ) + + // Check for any type of trigger block (dedicated triggers or trigger-mode blocks) + const hasTriggerBlocks = this.actualWorkflow.blocks.some((block) => { + // Check if it's a dedicated trigger block (category: 'triggers') + if (block.metadata?.category === 'triggers') return true + // Check if it's a block with trigger mode enabled + if (block.config?.params?.triggerMode === true) return true + return false + }) + + if (hasTriggerBlocks) { + // When triggers exist (either dedicated or trigger-mode), we allow execution without a starter block + // The actual start block will be determined at runtime based on the execution context } else { - // Default validation for starter block - const starterBlock = this.actualWorkflow.blocks.find( - (block) => block.metadata?.id === BlockType.STARTER - ) + // Legacy workflows: require a valid starter block and basic connection checks if (!starterBlock || !starterBlock.enabled) { throw new Error('Workflow must have an enabled starter block') } @@ -652,22 +666,15 @@ export class Executor { throw new Error('Starter block cannot have incoming connections') } - // Check if there are any trigger blocks on the canvas - const hasTriggerBlocks = this.actualWorkflow.blocks.some((block) => { - return block.metadata?.category === 'triggers' || block.config?.params?.triggerMode === true - }) - - // Only check outgoing connections for starter blocks if there are no trigger blocks - if (!hasTriggerBlocks) { - const outgoingFromStarter = this.actualWorkflow.connections.filter( - (conn) => conn.source === starterBlock.id - ) - if (outgoingFromStarter.length === 0) { - throw new Error('Starter block must have at least one outgoing connection') - } + const outgoingFromStarter = this.actualWorkflow.connections.filter( + (conn) => conn.source === starterBlock.id + ) + if (outgoingFromStarter.length === 0) { + throw new Error('Starter block must have at least one outgoing connection') } } + // General graph validations const blockIds = new Set(this.actualWorkflow.blocks.map((block) => block.id)) for (const conn of this.actualWorkflow.connections) { if (!blockIds.has(conn.source)) { @@ -762,20 +769,54 @@ export class Executor { // Determine which block to initialize as the starting point let initBlock: SerializedBlock | undefined if (startBlockId) { - // Starting from a specific block (webhook trigger or schedule trigger) + // Starting from a specific block (webhook trigger, schedule trigger, or new trigger blocks) initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) } else { - // Default to starter block + // Default to starter block (legacy) or find any trigger block initBlock = this.actualWorkflow.blocks.find( (block) => block.metadata?.id === BlockType.STARTER ) + + // If no starter block, look for appropriate trigger block based on context + if (!initBlock) { + if (this.isChildExecution) { + const inputTriggerBlocks = this.actualWorkflow.blocks.filter( + (block) => block.metadata?.id === 'input_trigger' + ) + if (inputTriggerBlocks.length === 1) { + initBlock = inputTriggerBlocks[0] + } else if (inputTriggerBlocks.length > 1) { + throw new Error('Child workflow has multiple Input Trigger blocks. Keep only one.') + } + } else { + // Parent workflows can use any trigger block (dedicated or trigger-mode) + const triggerBlocks = this.actualWorkflow.blocks.filter( + (block) => + block.metadata?.id === 'input_trigger' || + block.metadata?.id === 'api_trigger' || + block.metadata?.id === 'chat_trigger' || + block.metadata?.category === 'triggers' || + block.config?.params?.triggerMode === true + ) + if (triggerBlocks.length > 0) { + initBlock = triggerBlocks[0] + } + } + } } if (initBlock) { // Initialize the starting block with the workflow input try { + // Get inputFormat from either old location (config.params) or new location (metadata.subBlocks) const blockParams = initBlock.config.params - const inputFormat = blockParams?.inputFormat + let inputFormat = blockParams?.inputFormat + + // For new trigger blocks (api_trigger, etc), inputFormat is in metadata.subBlocks + const metadataWithSubBlocks = initBlock.metadata as any + if (!inputFormat && metadataWithSubBlocks?.subBlocks?.inputFormat?.value) { + inputFormat = metadataWithSubBlocks.subBlocks.inputFormat.value + } // If input format is defined, structure the input according to the schema if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) { @@ -841,11 +882,31 @@ export class Executor { // Use the structured input if we processed fields, otherwise use raw input const finalInput = hasProcessedFields ? structuredInput : rawInputData - // Initialize the starting block with structured input (flattened) - const blockOutput = { - input: finalInput, - conversationId: this.workflowInput?.conversationId, // Add conversationId to root - ...finalInput, // Add input fields directly at top level + // Initialize the starting block with structured input + let blockOutput: any + + // For API/Input triggers, normalize primitives and mirror objects under input + if ( + initBlock.metadata?.id === 'api_trigger' || + initBlock.metadata?.id === 'input_trigger' + ) { + const isObject = + finalInput !== null && typeof finalInput === 'object' && !Array.isArray(finalInput) + if (isObject) { + blockOutput = { ...finalInput } + // Provide a mirrored input object for universal references + blockOutput.input = { ...finalInput } + } else { + // Primitive input: only expose under input + blockOutput = { input: finalInput } + } + } else { + // For legacy starter blocks, keep the old behavior + blockOutput = { + input: finalInput, + conversationId: this.workflowInput?.conversationId, // Add conversationId to root + ...finalInput, // Add input fields directly at top level + } } // Add files if present (for all trigger types) @@ -863,54 +924,81 @@ export class Executor { // This ensures files are captured in trace spans and execution logs this.createStartedBlockWithFilesLog(initBlock, blockOutput, context) } else { - // Handle structured input (like API calls or chat messages) - if (this.workflowInput && typeof this.workflowInput === 'object') { - // Check if this is a chat workflow input (has both input and conversationId) - if ( - Object.hasOwn(this.workflowInput, 'input') && - Object.hasOwn(this.workflowInput, 'conversationId') - ) { - // Chat workflow: extract input, conversationId, and files to root level - const starterOutput: any = { - input: this.workflowInput.input, - conversationId: this.workflowInput.conversationId, - } + // Handle triggers without inputFormat + let starterOutput: any + + // Handle different trigger types + if (initBlock.metadata?.id === 'chat_trigger') { + // Chat trigger: extract input, conversationId, and files + starterOutput = { + input: this.workflowInput?.input || '', + conversationId: this.workflowInput?.conversationId || '', + } - // Add files if present - if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { - starterOutput.files = this.workflowInput.files + if (this.workflowInput?.files && Array.isArray(this.workflowInput.files)) { + starterOutput.files = this.workflowInput.files + } + } else if ( + initBlock.metadata?.id === 'api_trigger' || + initBlock.metadata?.id === 'input_trigger' + ) { + // API/Input trigger without inputFormat: normalize primitives and mirror objects under input + const rawCandidate = + this.workflowInput?.input !== undefined + ? this.workflowInput.input + : this.workflowInput + const isObject = + rawCandidate !== null && + typeof rawCandidate === 'object' && + !Array.isArray(rawCandidate) + if (isObject) { + starterOutput = { + ...(rawCandidate as Record), + input: { ...(rawCandidate as Record) }, } - - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) - - // Create a block log for the starter block if it has files - // This ensures files are captured in trace spans and execution logs - this.createStartedBlockWithFilesLog(initBlock, starterOutput, context) } else { - // API workflow: spread the raw data directly (no wrapping) - const starterOutput = { ...this.workflowInput } - - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + starterOutput = { input: rawCandidate } } } else { - // Fallback for primitive input values - const starterOutput = { - input: this.workflowInput, + // Legacy starter block handling + if (this.workflowInput && typeof this.workflowInput === 'object') { + // Check if this is a chat workflow input (has both input and conversationId) + if ( + Object.hasOwn(this.workflowInput, 'input') && + Object.hasOwn(this.workflowInput, 'conversationId') + ) { + // Chat workflow: extract input, conversationId, and files to root level + starterOutput = { + input: this.workflowInput.input, + conversationId: this.workflowInput.conversationId, + } + + // Add files if present + if (this.workflowInput.files && Array.isArray(this.workflowInput.files)) { + starterOutput.files = this.workflowInput.files + } + } else { + // API workflow: spread the raw data directly (no wrapping) + starterOutput = { ...this.workflowInput } + } + } else { + // Fallback for primitive input values + starterOutput = { + input: this.workflowInput, + } } + } - context.blockStates.set(initBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + context.blockStates.set(initBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + + // Create a block log for the starter block if it has files + // This ensures files are captured in trace spans and execution logs + if (starterOutput.files) { + this.createStartedBlockWithFilesLog(initBlock, starterOutput, context) } } } catch (e) { diff --git a/apps/sim/executor/resolver/resolver.ts b/apps/sim/executor/resolver/resolver.ts index c2fca222cb..f03d9a0c43 100644 --- a/apps/sim/executor/resolver/resolver.ts +++ b/apps/sim/executor/resolver/resolver.ts @@ -1,6 +1,7 @@ import { BlockPathCalculator } from '@/lib/block-path-calculator' import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' +import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' @@ -520,15 +521,19 @@ export class InputResolver { // System references and regular block references are both processed // Accessibility validation happens later in validateBlockReference - // Special case for "start" references - if (blockRef.toLowerCase() === 'start') { - // Find the starter block - const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter') - if (starterBlock) { - const blockState = context.blockStates.get(starterBlock.id) + // Special case for trigger block references (start, api, chat, manual) + const blockRefLower = blockRef.toLowerCase() + const triggerType = + TRIGGER_REFERENCE_ALIAS_MAP[blockRefLower as keyof typeof TRIGGER_REFERENCE_ALIAS_MAP] + if (triggerType) { + const triggerBlock = this.workflow.blocks.find( + (block) => block.metadata?.id === triggerType + ) + if (triggerBlock) { + const blockState = context.blockStates.get(triggerBlock.id) if (blockState) { - // For starter block, start directly with the flattened output - // This enables direct access to and + // For trigger blocks, start directly with the flattened output + // This enables direct access to , , etc let replacementValue: any = blockState.output for (const part of pathParts) { @@ -537,7 +542,7 @@ export class InputResolver { `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, replacementValue ) - throw new Error(`Invalid path "${part}" in "${path}" for starter block.`) + throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) } // Handle array indexing syntax like "files[0]" or "items[1]" @@ -550,14 +555,14 @@ export class InputResolver { const arrayValue = replacementValue[arrayName] if (!Array.isArray(arrayValue)) { throw new Error( - `Property "${arrayName}" is not an array in path "${path}" for starter block.` + `Property "${arrayName}" is not an array in path "${path}" for trigger block.` ) } // Then access the array element if (index < 0 || index >= arrayValue.length) { throw new Error( - `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for starter block.` + `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` ) } @@ -577,17 +582,22 @@ export class InputResolver { if (replacementValue === undefined) { logger.warn( - `[resolveBlockReferences] No value found at path "${part}" in starter block.` + `[resolveBlockReferences] No value found at path "${part}" in trigger block.` ) - throw new Error(`No value found at path "${path}" in starter block.`) + throw new Error(`No value found at path "${path}" in trigger block.`) } } // Format the value based on block type and path let formattedValue: string - // Special handling for all blocks referencing starter input - if (blockRef.toLowerCase() === 'start' && pathParts.join('.').includes('input')) { + // Special handling for all blocks referencing trigger input + // For starter and chat triggers, check for 'input' field. For API trigger, any field access counts + const isTriggerInputRef = + (blockRefLower === 'start' && pathParts.join('.').includes('input')) || + (blockRefLower === 'chat' && pathParts.join('.').includes('input')) || + (blockRefLower === 'api' && pathParts.length > 0) + if (isTriggerInputRef) { const blockType = currentBlock.metadata?.id // Format based on which block is consuming this value diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 8957dee9a6..e9953ee987 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -662,7 +662,8 @@ export function useCollaborativeWorkflow() { data?: Record, parentId?: string, extent?: 'parent', - autoConnectEdge?: Edge + autoConnectEdge?: Edge, + triggerMode?: boolean ) => { // Skip socket operations when in diff mode if (isShowingDiff) { @@ -695,6 +696,7 @@ export function useCollaborativeWorkflow() { horizontalHandles: true, isWide: false, advancedMode: false, + triggerMode: triggerMode || false, height: 0, parentId, extent, @@ -704,7 +706,7 @@ export function useCollaborativeWorkflow() { // Skip if applying remote changes if (isApplyingRemoteChange.current) { workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) @@ -729,7 +731,7 @@ export function useCollaborativeWorkflow() { // Apply locally first (immediate UI feedback) workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) @@ -774,7 +776,7 @@ export function useCollaborativeWorkflow() { horizontalHandles: true, isWide: false, advancedMode: false, - triggerMode: false, + triggerMode: triggerMode || false, height: 0, // Default height, will be set by the UI parentId, extent, @@ -801,7 +803,7 @@ export function useCollaborativeWorkflow() { // Apply locally workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: false, + triggerMode: triggerMode || false, }) if (autoConnectEdge) { workflowStore.addEdge(autoConnectEdge) diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 3b55729b6a..072d32f9fe 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -57,7 +57,16 @@ export interface SendMessageRequest { chatId?: string workflowId?: string mode?: 'ask' | 'agent' - depth?: 0 | 1 | 2 | 3 + model?: + | 'gpt-5-fast' + | 'gpt-5' + | 'gpt-5-medium' + | 'gpt-5-high' + | 'gpt-4o' + | 'gpt-4.1' + | 'o3' + | 'claude-4-sonnet' + | 'claude-4.1-opus' prefetch?: boolean createNewChat?: boolean stream?: boolean diff --git a/apps/sim/lib/copilot/registry.ts b/apps/sim/lib/copilot/registry.ts index 23caa59561..b2ce028755 100644 --- a/apps/sim/lib/copilot/registry.ts +++ b/apps/sim/lib/copilot/registry.ts @@ -9,9 +9,8 @@ export const ToolIds = z.enum([ 'get_workflow_console', 'get_blocks_and_tools', 'get_blocks_metadata', - 'get_block_best_practices', - 'get_build_workflow_examples', - 'get_edit_workflow_examples', + 'get_trigger_examples', + 'get_examples_rag', 'search_documentation', 'search_online', 'make_api_request', @@ -30,6 +29,7 @@ export const ToolIds = z.enum([ 'set_global_workflow_variables', // New 'oauth_request_access', + 'get_trigger_blocks', ]) export type ToolId = z.infer @@ -102,6 +102,8 @@ export const ToolArgSchemas = { blockIds: StringArray.min(1), }), + get_trigger_blocks: z.object({}), + get_block_best_practices: z.object({ blockIds: StringArray.min(1), }), @@ -114,6 +116,12 @@ export const ToolArgSchemas = { exampleIds: StringArray.min(1), }), + get_trigger_examples: z.object({}), + + get_examples_rag: z.object({ + query: z.string(), + }), + search_documentation: z.object({ query: z.string(), topK: NumberOptional, @@ -198,18 +206,10 @@ export const ToolSSESchemas = { get_workflow_console: toolCallSSEFor('get_workflow_console', ToolArgSchemas.get_workflow_console), get_blocks_and_tools: toolCallSSEFor('get_blocks_and_tools', ToolArgSchemas.get_blocks_and_tools), get_blocks_metadata: toolCallSSEFor('get_blocks_metadata', ToolArgSchemas.get_blocks_metadata), - get_block_best_practices: toolCallSSEFor( - 'get_block_best_practices', - ToolArgSchemas.get_block_best_practices - ), - get_build_workflow_examples: toolCallSSEFor( - 'get_build_workflow_examples', - ToolArgSchemas.get_build_workflow_examples - ), - get_edit_workflow_examples: toolCallSSEFor( - 'get_edit_workflow_examples', - ToolArgSchemas.get_edit_workflow_examples - ), + get_trigger_blocks: toolCallSSEFor('get_trigger_blocks', ToolArgSchemas.get_trigger_blocks), + + get_trigger_examples: toolCallSSEFor('get_trigger_examples', ToolArgSchemas.get_trigger_examples), + get_examples_rag: toolCallSSEFor('get_examples_rag', ToolArgSchemas.get_examples_rag), search_documentation: toolCallSSEFor('search_documentation', ToolArgSchemas.search_documentation), search_online: toolCallSSEFor('search_online', ToolArgSchemas.search_online), make_api_request: toolCallSSEFor('make_api_request', ToolArgSchemas.make_api_request), @@ -296,6 +296,7 @@ export const ToolResultSchemas = { get_workflow_console: z.object({ entries: z.array(ExecutionEntry) }), get_blocks_and_tools: z.object({ blocks: z.array(z.any()), tools: z.array(z.any()) }), get_blocks_metadata: z.object({ metadata: z.record(z.any()) }), + get_trigger_blocks: z.object({ triggerBlockIds: z.array(z.string()) }), get_block_best_practices: z.object({ bestPractices: z.array(z.any()) }), get_build_workflow_examples: z.object({ examples: z.array( @@ -311,6 +312,24 @@ export const ToolResultSchemas = { }) ), }), + get_trigger_examples: z.object({ + examples: z.array( + z.object({ + id: z.string(), + title: z.string().optional(), + operations: z.array(z.any()).optional(), + }) + ), + }), + get_examples_rag: z.object({ + examples: z.array( + z.object({ + id: z.string(), + title: z.string().optional(), + operations: z.array(z.any()).optional(), + }) + ), + }), search_documentation: z.object({ results: z.array(z.any()) }), search_online: z.object({ results: z.array(z.any()) }), make_api_request: z.object({ diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts index 34aa738c38..af50f30b2b 100644 --- a/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts +++ b/apps/sim/lib/copilot/tools/client/blocks/get-blocks-metadata.ts @@ -61,6 +61,7 @@ export class GetBlocksMetadataClientTool extends BaseClientTool { this.setState(ClientToolCallState.success) } catch (error: any) { const message = error instanceof Error ? error.message : String(error) + logger.error('Execute failed', { message }) await this.markToolComplete(500, message) this.setState(ClientToolCallState.error) } diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts new file mode 100644 index 0000000000..23f7066562 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/blocks/get-trigger-blocks.ts @@ -0,0 +1,64 @@ +import { ListFilter, Loader2, MinusCircle, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' +import { + ExecuteResponseSuccessSchema, + GetTriggerBlocksResult, +} from '@/lib/copilot/tools/shared/schemas' +import { createLogger } from '@/lib/logs/console/logger' + +export class GetTriggerBlocksClientTool extends BaseClientTool { + static readonly id = 'get_trigger_blocks' + + constructor(toolCallId: string) { + super(toolCallId, GetTriggerBlocksClientTool.id, GetTriggerBlocksClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Finding trigger blocks', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Found trigger blocks', icon: ListFilter }, + [ClientToolCallState.error]: { text: 'Failed to find trigger blocks', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted finding trigger blocks', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped finding trigger blocks', icon: MinusCircle }, + }, + interrupt: undefined, + } + + async execute(): Promise { + const logger = createLogger('GetTriggerBlocksClientTool') + try { + this.setState(ClientToolCallState.executing) + + const res = await fetch('/api/copilot/execute-copilot-server-tool', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolName: 'get_trigger_blocks', payload: {} }), + }) + if (!res.ok) { + const errorText = await res.text().catch(() => '') + try { + const errorJson = JSON.parse(errorText) + throw new Error(errorJson.error || errorText || `Server error (${res.status})`) + } catch { + throw new Error(errorText || `Server error (${res.status})`) + } + } + const json = await res.json() + const parsed = ExecuteResponseSuccessSchema.parse(json) + const result = GetTriggerBlocksResult.parse(parsed.result) + + await this.markToolComplete(200, 'Successfully retrieved trigger blocks', result) + this.setState(ClientToolCallState.success) + } catch (error: any) { + const message = error instanceof Error ? error.message : String(error) + await this.markToolComplete(500, message) + this.setState(ClientToolCallState.error) + } + } +} diff --git a/apps/sim/lib/copilot/tools/client/examples/get-examples-rag.ts b/apps/sim/lib/copilot/tools/client/examples/get-examples-rag.ts new file mode 100644 index 0000000000..ccc5db9c63 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/examples/get-examples-rag.ts @@ -0,0 +1,31 @@ +import { Loader2, MinusCircle, Search, XCircle } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class GetExamplesRagClientTool extends BaseClientTool { + static readonly id = 'get_examples_rag' + + constructor(toolCallId: string) { + super(toolCallId, GetExamplesRagClientTool.id, GetExamplesRagClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Fetching examples', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Fetching examples', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Fetching examples', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Fetched examples', icon: Search }, + [ClientToolCallState.error]: { text: 'Failed to fetch examples', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted getting examples', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped getting examples', icon: MinusCircle }, + }, + interrupt: undefined, + } + + async execute(): Promise { + return + } +} diff --git a/apps/sim/lib/copilot/tools/client/examples/get-trigger-examples.ts b/apps/sim/lib/copilot/tools/client/examples/get-trigger-examples.ts new file mode 100644 index 0000000000..f24ea48017 --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/examples/get-trigger-examples.ts @@ -0,0 +1,31 @@ +import { Loader2, MinusCircle, XCircle, Zap } from 'lucide-react' +import { + BaseClientTool, + type BaseClientToolMetadata, + ClientToolCallState, +} from '@/lib/copilot/tools/client/base-tool' + +export class GetTriggerExamplesClientTool extends BaseClientTool { + static readonly id = 'get_trigger_examples' + + constructor(toolCallId: string) { + super(toolCallId, GetTriggerExamplesClientTool.id, GetTriggerExamplesClientTool.metadata) + } + + static readonly metadata: BaseClientToolMetadata = { + displayNames: { + [ClientToolCallState.generating]: { text: 'Selecting a trigger', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Selecting a trigger', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Selecting a trigger', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Selected a trigger', icon: Zap }, + [ClientToolCallState.error]: { text: 'Failed to select a trigger', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted selecting a trigger', icon: MinusCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped selecting a trigger', icon: MinusCircle }, + }, + interrupt: undefined, + } + + async execute(): Promise { + return + } +} diff --git a/apps/sim/lib/copilot/tools/client/workflow/build-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/build-workflow.ts index 6c36ccd6cc..9adaa06bbc 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/build-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/build-workflow.ts @@ -79,7 +79,12 @@ export class BuildWorkflowClientTool extends BaseClientTool { }) if (!res.ok) { const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) + try { + const errorJson = JSON.parse(errorText) + throw new Error(errorJson.error || errorText || `Server error (${res.status})`) + } catch { + throw new Error(errorText || `Server error (${res.status})`) + } } const json = await res.json() @@ -111,6 +116,7 @@ export class BuildWorkflowClientTool extends BaseClientTool { } catch (error: any) { const message = error instanceof Error ? error.message : String(error) logger.error('execute error', { message }) + await this.markToolComplete(500, message) this.setState(ClientToolCallState.error) } } diff --git a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts index 50d3a5d6fc..1bc3fa84a4 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -135,7 +135,12 @@ export class EditWorkflowClientTool extends BaseClientTool { }) if (!res.ok) { const errorText = await res.text().catch(() => '') - throw new Error(errorText || `Server error (${res.status})`) + try { + const errorJson = JSON.parse(errorText) + throw new Error(errorJson.error || errorText || `Server error (${res.status})`) + } catch { + throw new Error(errorText || `Server error (${res.status})`) + } } const json = await res.json() @@ -169,6 +174,7 @@ export class EditWorkflowClientTool extends BaseClientTool { } catch (error: any) { const message = error instanceof Error ? error.message : String(error) logger.error('execute error', { message }) + await this.markToolComplete(500, message) this.setState(ClientToolCallState.error) } } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts index a44bade416..9609f70aec 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts @@ -5,7 +5,7 @@ import { } from '@/lib/copilot/tools/shared/schemas' import { createLogger } from '@/lib/logs/console/logger' import { registry as blockRegistry } from '@/blocks/registry' -import { tools as toolsRegistry } from '@/tools/registry' +import type { BlockConfig } from '@/blocks/types' export const getBlocksAndToolsServerTool: BaseServerTool< ReturnType, @@ -16,30 +16,46 @@ export const getBlocksAndToolsServerTool: BaseServerTool< const logger = createLogger('GetBlocksAndToolsServerTool') logger.debug('Executing get_blocks_and_tools') - const blocks: any[] = [] + type BlockListItem = { + type: string + name: string + description?: string + triggerAllowed?: boolean + } + const blocks: BlockListItem[] = [] Object.entries(blockRegistry) - .filter(([_, blockConfig]: any) => { - if ((blockConfig as any).hideFromToolbar) return false - return true - }) - .forEach(([blockType, blockConfig]: any) => { - blocks.push({ id: blockType, type: blockType, name: blockConfig.name || blockType }) + .filter(([, blockConfig]: [string, BlockConfig]) => !blockConfig.hideFromToolbar) + .forEach(([blockType, blockConfig]: [string, BlockConfig]) => { + blocks.push({ + type: blockType, + name: blockConfig.name, + description: blockConfig.longDescription, + triggerAllowed: 'triggerAllowed' in blockConfig ? !!blockConfig.triggerAllowed : false, + }) }) - const specialBlocks = { loop: { name: 'Loop' }, parallel: { name: 'Parallel' } } + const specialBlocks: Record = { + loop: { + name: 'Loop', + description: + 'Control flow block for iterating over collections or repeating actions in a loop', + }, + parallel: { + name: 'Parallel', + description: 'Control flow block for executing multiple branches simultaneously', + }, + } Object.entries(specialBlocks).forEach(([blockType, info]) => { - if (!blocks.some((b) => b.id === blockType)) { - blocks.push({ id: blockType, type: blockType, name: (info as any).name }) + if (!blocks.some((b) => b.type === blockType)) { + blocks.push({ + type: blockType, + name: info.name, + description: info.description, + }) } }) - const tools: any[] = Object.entries(toolsRegistry).map(([toolId, toolConfig]: any) => ({ - id: toolId, - type: toolId, - name: toolConfig?.name || toolId, - })) - - return GetBlocksAndToolsResult.parse({ blocks, tools }) + return GetBlocksAndToolsResult.parse({ blocks }) }, } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 9103bed30d..a91189de46 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -7,7 +7,55 @@ import { } from '@/lib/copilot/tools/shared/schemas' import { createLogger } from '@/lib/logs/console/logger' import { registry as blockRegistry } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' import { tools as toolsRegistry } from '@/tools/registry' +import { TRIGGER_REGISTRY } from '@/triggers' + +export interface CopilotSubblockMetadata { + id: string + type: string + title?: string + required?: boolean + description?: string +} + +export interface CopilotToolMetadata { + id: string + name: string + description?: string + inputs?: any + outputs?: any +} + +export interface CopilotTriggerMetadata { + id: string + outputs?: any +} + +export interface CopilotBlockMetadata { + id: string + name: string + description: string + commonParameters: Record + triggerAllowed?: boolean + authType?: 'OAuth' | 'API Key' | 'Bot Token' + tools: CopilotToolMetadata[] + triggers: CopilotTriggerMetadata[] + operationParameters: Record + operations?: Record< + string, + { + toolId?: string + toolName?: string + description?: string + inputs?: Record + outputs?: Record + parameters?: CopilotSubblockMetadata[] + } + > + yamlDocumentation?: string +} export const getBlocksMetadataServerTool: BaseServerTool< ReturnType, @@ -22,35 +70,100 @@ export const getBlocksMetadataServerTool: BaseServerTool< const logger = createLogger('GetBlocksMetadataServerTool') logger.debug('Executing get_blocks_metadata', { count: blockIds?.length }) - const result: Record = {} + const result: Record = {} for (const blockId of blockIds || []) { - let metadata: any = {} + let metadata: any if (SPECIAL_BLOCKS_METADATA[blockId]) { - metadata = { ...SPECIAL_BLOCKS_METADATA[blockId] } - metadata.tools = metadata.tools?.access || [] + const specialBlock = SPECIAL_BLOCKS_METADATA[blockId] + const { operationParameters } = splitParametersByOperation( + specialBlock.subBlocks || [], + specialBlock.inputs || {} + ) + metadata = { + id: specialBlock.id, + name: specialBlock.name, + description: specialBlock.description || '', + commonParameters: specialBlock.inputs || {}, + tools: [], + triggers: [], + operationParameters, + } + ;(metadata as any).subBlocks = undefined } else { - const blockConfig: any = (blockRegistry as any)[blockId] + const blockConfig: BlockConfig | undefined = blockRegistry[blockId] if (!blockConfig) { logger.debug('Block not found in registry', { blockId }) continue } + + if (blockConfig.hideFromToolbar) { + logger.debug('Skipping block hidden from toolbar', { blockId }) + continue + } + const tools: CopilotToolMetadata[] = Array.isArray(blockConfig.tools?.access) + ? blockConfig.tools!.access.map((toolId) => { + const tool = toolsRegistry[toolId] + if (!tool) return { id: toolId, name: toolId } + return { + id: toolId, + name: tool.name || toolId, + description: tool.description || '', + inputs: tool.params || {}, + outputs: tool.outputs || {}, + } + }) + : [] + + const triggers: CopilotTriggerMetadata[] = [] + const availableTriggerIds = blockConfig.triggers?.available || [] + for (const tid of availableTriggerIds) { + const trig = TRIGGER_REGISTRY[tid] + triggers.push({ + id: tid, + outputs: trig?.outputs || {}, + }) + } + + const blockInputs = computeBlockLevelInputs(blockConfig) + const { operationParameters } = splitParametersByOperation( + Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : [], + blockInputs + ) + + const operationInputs = computeOperationLevelInputs(blockConfig) + const operationIds = resolveOperationIds(blockConfig, operationParameters) + const operations: Record = {} + for (const opId of operationIds) { + const resolvedToolId = resolveToolIdForOperation(blockConfig, opId) + const toolCfg = resolvedToolId ? toolsRegistry[resolvedToolId] : undefined + const toolParams: Record = toolCfg?.params || {} + const toolOutputs: Record = toolCfg?.outputs || {} + const filteredToolParams: Record = {} + for (const [k, v] of Object.entries(toolParams)) { + if (!(k in blockInputs)) filteredToolParams[k] = v + } + operations[opId] = { + toolId: resolvedToolId, + toolName: toolCfg?.name || resolvedToolId, + description: toolCfg?.description || undefined, + inputs: { ...filteredToolParams, ...(operationInputs[opId] || {}) }, + outputs: toolOutputs, + parameters: operationParameters[opId] || [], + } + } + metadata = { id: blockId, name: blockConfig.name || blockId, - description: blockConfig.description || '', - longDescription: blockConfig.longDescription, - category: blockConfig.category, - bgColor: blockConfig.bgColor, - inputs: blockConfig.inputs || {}, - outputs: blockConfig.outputs || {}, - tools: blockConfig.tools?.access || [], - hideFromToolbar: blockConfig.hideFromToolbar, - } - if (blockConfig.subBlocks && Array.isArray(blockConfig.subBlocks)) { - metadata.subBlocks = processSubBlocks(blockConfig.subBlocks) - } else { - metadata.subBlocks = [] + description: blockConfig.longDescription || blockConfig.description || '', + commonParameters: blockInputs, + triggerAllowed: !!blockConfig.triggerAllowed, + authType: resolveAuthType(blockConfig.authMode), + tools, + triggers, + operationParameters, + operations, } } @@ -73,87 +186,180 @@ export const getBlocksMetadataServerTool: BaseServerTool< } } catch {} - if (Array.isArray(metadata.tools) && metadata.tools.length > 0) { - metadata.toolDetails = {} - for (const toolId of metadata.tools) { - const tool = (toolsRegistry as any)[toolId] - if (tool) { - metadata.toolDetails[toolId] = { name: tool.name, description: tool.description } - } - } + if (metadata) { + result[blockId] = metadata as CopilotBlockMetadata } - - result[blockId] = metadata } return GetBlocksMetadataResult.parse({ metadata: result }) }, } -function resolveSubBlockOptions(options: any): any[] { +function simplifySubBlock(sb: any): CopilotSubblockMetadata { + const simplified: CopilotSubblockMetadata = { + id: sb.id, + type: sb.type, + } + if (sb.title) simplified.title = sb.title + if (sb.required) simplified.required = sb.required + if (sb.description) simplified.description = sb.description + return simplified +} + +function resolveAuthType( + authMode: AuthMode | undefined +): 'OAuth' | 'API Key' | 'Bot Token' | undefined { + if (!authMode) return undefined + if (authMode === AuthMode.OAuth) return 'OAuth' + if (authMode === AuthMode.ApiKey) return 'API Key' + if (authMode === AuthMode.BotToken) return 'Bot Token' + return undefined +} + +function normalizeCondition(condition: any): any | undefined { try { - if (typeof options === 'function') { - const resolved = options() - return Array.isArray(resolved) ? resolved : [] + if (!condition) return undefined + if (typeof condition === 'function') { + return condition() } - return Array.isArray(options) ? options : [] + return condition } catch { - return [] + return undefined } } -function processSubBlocks(subBlocks: any[]): any[] { - if (!Array.isArray(subBlocks)) return [] - return subBlocks.map((subBlock) => { - const processed: any = { - id: subBlock.id, - title: subBlock.title, - type: subBlock.type, - layout: subBlock.layout, - mode: subBlock.mode, - required: subBlock.required, - placeholder: subBlock.placeholder, - description: subBlock.description, - hidden: subBlock.hidden, - condition: subBlock.condition, - min: subBlock.min, - max: subBlock.max, - step: subBlock.step, - integer: subBlock.integer, - rows: subBlock.rows, - password: subBlock.password, - multiSelect: subBlock.multiSelect, - language: subBlock.language, - generationType: subBlock.generationType, - provider: subBlock.provider, - serviceId: subBlock.serviceId, - requiredScopes: subBlock.requiredScopes, - mimeType: subBlock.mimeType, - acceptedTypes: subBlock.acceptedTypes, - multiple: subBlock.multiple, - maxSize: subBlock.maxSize, - connectionDroppable: subBlock.connectionDroppable, - columns: subBlock.columns, - value: typeof subBlock.value === 'function' ? 'function' : undefined, - wandConfig: subBlock.wandConfig, +function splitParametersByOperation( + subBlocks: any[], + blockInputsForDescriptions?: Record +): { + commonParameters: CopilotSubblockMetadata[] + operationParameters: Record +} { + const commonParameters: CopilotSubblockMetadata[] = [] + const operationParameters: Record = {} + + for (const sb of subBlocks || []) { + const cond = normalizeCondition(sb.condition) + const simplified = simplifySubBlock(sb) + + if (cond && cond.field === 'operation' && !cond.not && cond.value !== undefined) { + const values: any[] = Array.isArray(cond.value) ? cond.value : [cond.value] + for (const v of values) { + const key = String(v) + if (!operationParameters[key]) operationParameters[key] = [] + operationParameters[key].push(simplified) + } + } else { + // Override description from blockInputs if available (by id or canonicalParamId) + if (blockInputsForDescriptions) { + const candidates = [sb.id, sb.canonicalParamId].filter(Boolean) + for (const key of candidates) { + const bi = (blockInputsForDescriptions as any)[key as string] + if (bi && typeof bi.description === 'string') { + simplified.description = bi.description + break + } + } + } + commonParameters.push(simplified) + } + } + + return { commonParameters, operationParameters } +} + +function computeBlockLevelInputs(blockConfig: BlockConfig): Record { + const inputs = blockConfig.inputs || {} + const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : [] + + // Build quick lookup of subBlocks by id and canonicalParamId + const byParamKey: Record = {} + for (const sb of subBlocks) { + if (sb.id) { + byParamKey[sb.id] = byParamKey[sb.id] || [] + byParamKey[sb.id].push(sb) + } + if (sb.canonicalParamId) { + byParamKey[sb.canonicalParamId] = byParamKey[sb.canonicalParamId] || [] + byParamKey[sb.canonicalParamId].push(sb) + } + } + + const blockInputs: Record = {} + for (const key of Object.keys(inputs)) { + const sbs = byParamKey[key] || [] + // If any related subBlock is gated by operation, treat as operation-level and exclude + const isOperationGated = sbs.some((sb) => { + const cond = normalizeCondition(sb.condition) + return cond && cond.field === 'operation' && !cond.not && cond.value !== undefined + }) + if (!isOperationGated) { + blockInputs[key] = inputs[key] } - if (subBlock.options) { - const resolvedOptions = resolveSubBlockOptions(subBlock.options) - processed.options = resolvedOptions.map((option: any) => ({ - label: option.label, - id: option.id, - hasIcon: !!option.icon, - })) + } + + return blockInputs +} + +function computeOperationLevelInputs( + blockConfig: BlockConfig +): Record> { + const inputs = blockConfig.inputs || {} + const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : [] + + const opInputs: Record> = {} + + // Map subblocks to inputs keys via id or canonicalParamId and collect by operation + for (const sb of subBlocks) { + const cond = normalizeCondition(sb.condition) + if (!cond || cond.field !== 'operation' || cond.not) continue + const keys: string[] = [] + if (sb.canonicalParamId) keys.push(sb.canonicalParamId) + if (sb.id) keys.push(sb.id) + const values = Array.isArray(cond.value) ? cond.value : [cond.value] + for (const key of keys) { + if (!(key in inputs)) continue + for (const v of values) { + const op = String(v) + if (!opInputs[op]) opInputs[op] = {} + opInputs[op][key] = inputs[key] + } + } + } + + return opInputs +} + +function resolveOperationIds( + blockConfig: BlockConfig, + operationParameters: Record +): string[] { + // Prefer explicit operation subblock options if present + const opBlock = (blockConfig.subBlocks || []).find((sb) => sb.id === 'operation') + if (opBlock && Array.isArray(opBlock.options)) { + const ids = opBlock.options.map((o) => o.id).filter(Boolean) + if (ids.length > 0) return ids + } + // Fallback: keys from operationParameters + return Object.keys(operationParameters) +} + +function resolveToolIdForOperation(blockConfig: BlockConfig, opId: string): string | undefined { + try { + const toolSelector = blockConfig.tools?.config?.tool + if (typeof toolSelector === 'function') { + const maybeToolId = toolSelector({ operation: opId }) + if (typeof maybeToolId === 'string') return maybeToolId } - return Object.fromEntries(Object.entries(processed).filter(([_, v]) => v !== undefined)) - }) + } catch {} + return undefined } const DOCS_FILE_MAPPING: Record = {} const SPECIAL_BLOCKS_METADATA: Record = { loop: { - type: 'loop', + id: 'loop', name: 'Loop', description: 'Control flow block for iterating over collections or repeating actions', inputs: { @@ -168,7 +374,6 @@ const SPECIAL_BLOCKS_METADATA: Record = { currentItem: 'any', totalIterations: 'number', }, - tools: { access: [] }, subBlocks: [ { id: 'loopType', @@ -208,7 +413,7 @@ const SPECIAL_BLOCKS_METADATA: Record = { ], }, parallel: { - type: 'parallel', + id: 'parallel', name: 'Parallel', description: 'Control flow block for executing multiple branches simultaneously', inputs: { @@ -218,7 +423,6 @@ const SPECIAL_BLOCKS_METADATA: Record = { maxConcurrency: { type: 'number', required: false, default: 10, minimum: 1, maximum: 50 }, }, outputs: { results: 'array', branchId: 'number', branchItem: 'any', totalBranches: 'number' }, - tools: { access: [] }, subBlocks: [ { id: 'parallelType', diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts new file mode 100644 index 0000000000..a7cb0f3861 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { createLogger } from '@/lib/logs/console/logger' +import { registry as blockRegistry } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' + +// Define input and result schemas +export const GetTriggerBlocksInput = z.object({}) +export const GetTriggerBlocksResult = z.object({ + triggerBlockIds: z.array(z.string()), +}) + +export const getTriggerBlocksServerTool: BaseServerTool< + ReturnType, + ReturnType +> = { + name: 'get_trigger_blocks', + async execute() { + const logger = createLogger('GetTriggerBlocksServerTool') + logger.debug('Executing get_trigger_blocks') + + const triggerBlockIds: string[] = [] + + Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => { + // Skip hidden blocks + if (blockConfig.hideFromToolbar) return + + // Check if it's a trigger block (category: 'triggers') + if (blockConfig.category === 'triggers') { + triggerBlockIds.push(blockType) + } + // Check if it's a tool with trigger capability (triggerAllowed: true) + else if ('triggerAllowed' in blockConfig && blockConfig.triggerAllowed === true) { + triggerBlockIds.push(blockType) + } + // Check if it has a trigger-config subblock + else if (blockConfig.subBlocks?.some((subBlock) => subBlock.type === 'trigger-config')) { + triggerBlockIds.push(blockType) + } + }) + + // Sort alphabetically for consistency + triggerBlockIds.sort() + + logger.debug(`Found ${triggerBlockIds.length} trigger blocks`) + return GetTriggerBlocksResult.parse({ triggerBlockIds }) + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 14bc621876..ab59fc9313 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -1,6 +1,7 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getBlocksAndToolsServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-and-tools' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' import { listGDriveFilesServerTool } from '@/lib/copilot/tools/server/gdrive/list-files' import { readGDriveFileServerTool } from '@/lib/copilot/tools/server/gdrive/read-file' @@ -20,6 +21,8 @@ import { GetBlocksAndToolsResult, GetBlocksMetadataInput, GetBlocksMetadataResult, + GetTriggerBlocksInput, + GetTriggerBlocksResult, } from '@/lib/copilot/tools/shared/schemas' import { createLogger } from '@/lib/logs/console/logger' @@ -34,6 +37,7 @@ const logger = createLogger('ServerToolRouter') // Register tools serverToolRegistry[getBlocksAndToolsServerTool.name] = getBlocksAndToolsServerTool serverToolRegistry[getBlocksMetadataServerTool.name] = getBlocksMetadataServerTool +serverToolRegistry[getTriggerBlocksServerTool.name] = getTriggerBlocksServerTool serverToolRegistry[buildWorkflowServerTool.name] = buildWorkflowServerTool serverToolRegistry[editWorkflowServerTool.name] = editWorkflowServerTool serverToolRegistry[getWorkflowConsoleServerTool.name] = getWorkflowConsoleServerTool @@ -70,6 +74,9 @@ export async function routeExecution(toolName: string, payload: unknown): Promis if (toolName === 'get_blocks_metadata') { args = GetBlocksMetadataInput.parse(args) } + if (toolName === 'get_trigger_blocks') { + args = GetTriggerBlocksInput.parse(args) + } if (toolName === 'build_workflow') { args = BuildWorkflowInput.parse(args) } @@ -82,6 +89,9 @@ export async function routeExecution(toolName: string, payload: unknown): Promis if (toolName === 'get_blocks_metadata') { return GetBlocksMetadataResult.parse(result) } + if (toolName === 'get_trigger_blocks') { + return GetTriggerBlocksResult.parse(result) + } if (toolName === 'build_workflow') { return BuildWorkflowResult.parse(result) } diff --git a/apps/sim/lib/copilot/tools/server/workflow/build-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/build-workflow.ts index 7b31b67cf4..112112d55d 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/build-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/build-workflow.ts @@ -1,8 +1,9 @@ import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { type BuildWorkflowInput, BuildWorkflowResult } from '@/lib/copilot/tools/shared/schemas' +import type { BuildWorkflowInput, BuildWorkflowResult } from '@/lib/copilot/tools/shared/schemas' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent' +import { validateWorkflowState } from '@/lib/workflows/validation' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -70,70 +71,79 @@ export const buildWorkflowServerTool: BaseServerTool< errors: conversionResult.errors, warnings: conversionResult.warnings, }) - return BuildWorkflowResult.parse({ - success: false, - message: `Failed to convert YAML workflow: ${Array.isArray(conversionResult.errors) ? conversionResult.errors.join(', ') : 'Unknown errors'}`, - yamlContent, - description, - }) + throw new Error(conversionResult.errors?.join(', ') || 'Failed to convert YAML to workflow') } - const { workflowState } = conversionResult + const workflowState = conversionResult.workflowState - const previewWorkflowState = { - blocks: {} as Record, - edges: [] as any[], - loops: {} as Record, - parallels: {} as Record, - lastSaved: Date.now(), - isDeployed: false, - } + // Validate the workflow state before returning + const validation = validateWorkflowState(workflowState, { sanitize: true }) - const blockIdMapping = new Map() - Object.keys(workflowState.blocks).forEach((blockId: string) => { - const previewId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` - blockIdMapping.set(blockId, previewId) - }) + if (!validation.valid) { + logger.error('Generated workflow state is invalid', { + errors: validation.errors, + warnings: validation.warnings, + }) + throw new Error(`Invalid workflow: ${validation.errors.join('; ')}`) + } - for (const [originalId, block] of Object.entries(workflowState.blocks)) { - const previewBlockId = blockIdMapping.get(originalId as string)! - const typedBlock = block as any - ;(previewWorkflowState.blocks as any)[previewBlockId] = { - ...typedBlock, - id: previewBlockId, - position: typedBlock.position || { x: 0, y: 0 }, - enabled: true, - } + if (validation.warnings.length > 0) { + logger.warn('Workflow validation warnings', { + warnings: validation.warnings, + }) } - ;(previewWorkflowState as any).edges = (workflowState.edges as any[]).map((edge: any) => ({ - ...edge, - id: `edge-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, - source: blockIdMapping.get(edge.source) || edge.source, - target: blockIdMapping.get(edge.target) || edge.target, - })) + // Use sanitized state if available + const finalWorkflowState = validation.sanitizedState || workflowState - const blocksCount = Object.keys((previewWorkflowState as any).blocks).length - const edgesCount = (previewWorkflowState as any).edges.length + // Apply positions using smart layout + const positionResponse = await fetch(`${SIM_AGENT_API_URL}/api/yaml/apply-layout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowState: finalWorkflowState, + options: { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center', + padding: { + x: 250, + y: 250, + }, + }, + }), + }) - logger.info('Workflow built successfully', { blocksCount, edgesCount }) + if (!positionResponse.ok) { + const errorText = await positionResponse.text().catch(() => '') + logger.warn('Failed to apply layout to workflow', { + status: positionResponse.status, + error: errorText, + }) + // Non-critical error - continue with unpositioned workflow + } else { + const layoutResult = await positionResponse.json() + if (layoutResult.success && layoutResult.workflowState) { + // Update the workflow state with positioned blocks + Object.assign(finalWorkflowState, layoutResult.workflowState) + } + } - return BuildWorkflowResult.parse({ + return { success: true, - message: `Successfully built workflow with ${blocksCount} blocks and ${edgesCount} connections`, + workflowState: finalWorkflowState, yamlContent, + message: `Successfully built workflow with ${Object.keys(finalWorkflowState.blocks).length} blocks`, description: description || 'Built workflow', - workflowState: previewWorkflowState, - data: { blocksCount, edgesCount }, - }) + } } catch (error: any) { - logger.error('Failed to build workflow:', error) - return BuildWorkflowResult.parse({ - success: false, - message: `Workflow build failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - yamlContent, - description, - }) + logger.error('Error building workflow', error) + throw error } }, } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 244d3fe4e5..4e336c6fcb 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -6,6 +6,7 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { validateWorkflowState } from '@/lib/workflows/validation' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -255,9 +256,55 @@ export const editWorkflowServerTool: BaseServerTool = { const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) + // Convert the modified YAML back to workflow state for validation + const validationResponse = await fetch(`${SIM_AGENT_API_URL}/api/yaml/to-workflow`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + yamlContent: modifiedYaml, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString(), + }, + options: { generateNewIds: false, preservePositions: true }, + }), + }) + + if (!validationResponse.ok) { + throw new Error(`Failed to validate edited workflow: ${validationResponse.statusText}`) + } + + const validationResult = await validationResponse.json() + if (!validationResult.success || !validationResult.workflowState) { + throw new Error( + validationResult.errors?.join(', ') || 'Failed to convert edited YAML to workflow' + ) + } + + // Validate the workflow state + const validation = validateWorkflowState(validationResult.workflowState, { sanitize: true }) + + if (!validation.valid) { + logger.error('Edited workflow state is invalid', { + errors: validation.errors, + warnings: validation.warnings, + }) + throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`) + } + + if (validation.warnings.length > 0) { + logger.warn('Edited workflow validation warnings', { + warnings: validation.warnings, + }) + } + logger.info('edit_workflow generated modified YAML', { operationCount: operations.length, modifiedYamlLength: modifiedYaml.length, + validationErrors: validation.errors.length, + validationWarnings: validation.warnings.length, }) return { success: true, yamlContent: modifiedYaml } diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index 2ee5bd07a0..dde822a1fc 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -10,8 +10,16 @@ export type ExecuteResponseSuccess = z.infer @@ -20,6 +28,13 @@ export const GetBlocksMetadataInput = z.object({ blockIds: z.array(z.string()).m export const GetBlocksMetadataResult = z.object({ metadata: z.record(z.any()) }) export type GetBlocksMetadataResultType = z.infer +// get_trigger_blocks +export const GetTriggerBlocksInput = z.object({}) +export const GetTriggerBlocksResult = z.object({ + triggerBlockIds: z.array(z.string()), +}) +export type GetTriggerBlocksResultType = z.infer + // build_workflow export const BuildWorkflowInput = z.object({ yamlContent: z.string(), diff --git a/apps/sim/lib/sim-agent/constants.ts b/apps/sim/lib/sim-agent/constants.ts index 41ee3900c5..3857f195d9 100644 --- a/apps/sim/lib/sim-agent/constants.ts +++ b/apps/sim/lib/sim-agent/constants.ts @@ -1 +1,2 @@ -export const SIM_AGENT_API_URL_DEFAULT = 'https://agent.sim.ai' +export const SIM_AGENT_API_URL_DEFAULT = 'https://d2vaeznw6mw0n7.cloudfront.net' +export const SIM_AGENT_VERSION = '1.0.0' diff --git a/apps/sim/lib/sim-agent/index.ts b/apps/sim/lib/sim-agent/index.ts index 20183cdaec..f2fe623f02 100644 --- a/apps/sim/lib/sim-agent/index.ts +++ b/apps/sim/lib/sim-agent/index.ts @@ -2,7 +2,7 @@ export type { SimAgentRequest, SimAgentResponse } from './client' export { SimAgentClient, simAgentClient } from './client' -export { SIM_AGENT_API_URL_DEFAULT } from './constants' +export { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from './constants' // Import for default export import { simAgentClient } from './client' diff --git a/apps/sim/lib/workflows/block-outputs.ts b/apps/sim/lib/workflows/block-outputs.ts new file mode 100644 index 0000000000..f7184c4a71 --- /dev/null +++ b/apps/sim/lib/workflows/block-outputs.ts @@ -0,0 +1,114 @@ +import { getBlock } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' + +/** + * Get the effective outputs for a block, including dynamic outputs from inputFormat + */ +export function getBlockOutputs( + blockType: string, + subBlocks?: Record +): Record { + const blockConfig = getBlock(blockType) + if (!blockConfig) return {} + + // Start with the static outputs defined in the config + let outputs = { ...(blockConfig.outputs || {}) } + + // Special handling for starter block (legacy) + if (blockType === 'starter') { + const startWorkflowValue = subBlocks?.startWorkflow?.value + + if (startWorkflowValue === 'chat') { + // Chat mode outputs + return { + input: { type: 'string', description: 'User message' }, + conversationId: { type: 'string', description: 'Conversation ID' }, + files: { type: 'array', description: 'Uploaded files' }, + } + } + if ( + startWorkflowValue === 'api' || + startWorkflowValue === 'run' || + startWorkflowValue === 'manual' + ) { + // API/manual mode - use inputFormat fields only + const inputFormatValue = subBlocks?.inputFormat?.value + outputs = {} + + if (Array.isArray(inputFormatValue)) { + inputFormatValue.forEach((field: { name?: string; type?: string }) => { + if (field.name && field.name.trim() !== '') { + outputs[field.name] = { + type: (field.type || 'any') as any, + description: `Field from input format`, + } + } + }) + } + + return outputs + } + } + + // For blocks with inputFormat, add dynamic outputs + if (hasInputFormat(blockConfig) && subBlocks?.inputFormat?.value) { + const inputFormatValue = subBlocks.inputFormat.value + + if (Array.isArray(inputFormatValue)) { + // For API and Input triggers, only use inputFormat fields + if (blockType === 'api_trigger' || blockType === 'input_trigger') { + outputs = {} // Clear all default outputs + + // Add each field from inputFormat as an output at root level + inputFormatValue.forEach((field: { name?: string; type?: string }) => { + if (field.name && field.name.trim() !== '') { + outputs[field.name] = { + type: (field.type || 'any') as any, + description: `Field from input format`, + } + } + }) + } + } else if (blockType === 'api_trigger' || blockType === 'input_trigger') { + // If no inputFormat defined, API/Input trigger has no outputs + outputs = {} + } + } + + return outputs +} + +/** + * Check if a block config has an inputFormat sub-block + */ +function hasInputFormat(blockConfig: BlockConfig): boolean { + return blockConfig.subBlocks?.some((sb) => sb.type === 'input-format') || false +} + +/** + * Get output paths for a block (for tag dropdown) + */ +export function getBlockOutputPaths(blockType: string, subBlocks?: Record): string[] { + const outputs = getBlockOutputs(blockType, subBlocks) + return Object.keys(outputs) +} + +/** + * Get the type of a specific output path + */ +export function getBlockOutputType( + blockType: string, + outputPath: string, + subBlocks?: Record +): string { + const outputs = getBlockOutputs(blockType, subBlocks) + const output = outputs[outputPath] + + if (!output) return 'any' + + if (typeof output === 'object' && 'type' in output) { + return output.type + } + + return typeof output === 'string' ? output : 'any' +} diff --git a/apps/sim/lib/workflows/trigger-utils.ts b/apps/sim/lib/workflows/trigger-utils.ts new file mode 100644 index 0000000000..931039188d --- /dev/null +++ b/apps/sim/lib/workflows/trigger-utils.ts @@ -0,0 +1,108 @@ +import { getAllBlocks, getBlock } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' + +export interface TriggerInfo { + id: string + name: string + description: string + icon: React.ComponentType<{ className?: string }> + color: string + category: 'core' | 'integration' + enableTriggerMode?: boolean +} + +/** + * Get all blocks that can act as triggers + * This includes both dedicated trigger blocks and tools with trigger capabilities + */ +export function getAllTriggerBlocks(): TriggerInfo[] { + const allBlocks = getAllBlocks() + const triggers: TriggerInfo[] = [] + + for (const block of allBlocks) { + // Skip hidden blocks + if (block.hideFromToolbar) continue + + // Check if it's a core trigger block (category: 'triggers') + if (block.category === 'triggers') { + triggers.push({ + id: block.type, + name: block.name, + description: block.description, + icon: block.icon, + color: block.bgColor, + category: 'core', + }) + } + // Check if it's a tool with trigger capability (has trigger-config subblock) + else if (hasTriggerCapability(block)) { + triggers.push({ + id: block.type, + name: block.name, + description: block.description.replace(' or trigger workflows from ', ', trigger from '), + icon: block.icon, + color: block.bgColor, + category: 'integration', + enableTriggerMode: true, + }) + } + } + + // Sort: core triggers first, then integration triggers, alphabetically within each category + return triggers.sort((a, b) => { + if (a.category !== b.category) { + return a.category === 'core' ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) +} + +/** + * Check if a block has trigger capability (contains a trigger-config subblock) + */ +export function hasTriggerCapability(block: BlockConfig): boolean { + return block.subBlocks.some((subBlock) => subBlock.type === 'trigger-config') +} + +/** + * Get blocks that should appear in the triggers tab + * This includes all trigger blocks and tools with trigger mode + */ +export function getTriggersForSidebar(): BlockConfig[] { + const allBlocks = getAllBlocks() + return allBlocks.filter((block) => { + if (block.hideFromToolbar) return false + // Include blocks with triggers category or trigger-config subblock + return block.category === 'triggers' || hasTriggerCapability(block) + }) +} + +/** + * Get blocks that should appear in the blocks tab + * This excludes only dedicated trigger blocks, not tools with trigger capability + */ +export function getBlocksForSidebar(): BlockConfig[] { + const allBlocks = getAllBlocks() + return allBlocks.filter((block) => { + if (block.hideFromToolbar) return false + if (block.type === 'starter') return false // Legacy block + // Only exclude blocks with 'triggers' category + // Tools with trigger capability should still appear in blocks tab + return block.category !== 'triggers' + }) +} + +/** + * Get the proper display name for a trigger block in the UI + */ +export function getTriggerDisplayName(blockType: string): string { + const block = getBlock(blockType) + if (!block) return blockType + + // Special case for generic_webhook - show as "Webhook" in UI + if (blockType === 'generic_webhook') { + return 'Webhook' + } + + return block.name +} diff --git a/apps/sim/lib/workflows/triggers.ts b/apps/sim/lib/workflows/triggers.ts new file mode 100644 index 0000000000..218046c960 --- /dev/null +++ b/apps/sim/lib/workflows/triggers.ts @@ -0,0 +1,331 @@ +import { getBlock } from '@/blocks' + +/** + * Unified trigger type definitions + */ +export const TRIGGER_TYPES = { + INPUT: 'input_trigger', + CHAT: 'chat_trigger', + API: 'api_trigger', + WEBHOOK: 'webhook', + SCHEDULE: 'schedule', + STARTER: 'starter', // Legacy +} as const + +export type TriggerType = (typeof TRIGGER_TYPES)[keyof typeof TRIGGER_TYPES] + +/** + * Mapping from reference alias (used in inline refs like , , etc.) + * to concrete trigger block type identifiers used across the system. + */ +export const TRIGGER_REFERENCE_ALIAS_MAP = { + start: TRIGGER_TYPES.STARTER, + api: TRIGGER_TYPES.API, + chat: TRIGGER_TYPES.CHAT, + manual: TRIGGER_TYPES.INPUT, +} as const + +export type TriggerReferenceAlias = keyof typeof TRIGGER_REFERENCE_ALIAS_MAP + +/** + * Trigger classification and utilities + */ +export class TriggerUtils { + /** + * Check if a block is any kind of trigger + */ + static isTriggerBlock(block: { type: string; triggerMode?: boolean }): boolean { + const blockConfig = getBlock(block.type) + + return ( + // New trigger blocks (explicit category) + blockConfig?.category === 'triggers' || + // Blocks with trigger mode enabled + block.triggerMode === true || + // Legacy starter block + block.type === TRIGGER_TYPES.STARTER + ) + } + + /** + * Check if a block is a specific trigger type + */ + static isTriggerType(block: { type: string }, triggerType: TriggerType): boolean { + return block.type === triggerType + } + + /** + * Check if a type string is any trigger type + */ + static isAnyTriggerType(type: string): boolean { + return Object.values(TRIGGER_TYPES).includes(type as TriggerType) + } + + /** + * Check if a block is a chat-compatible trigger + */ + static isChatTrigger(block: { type: string; subBlocks?: any }): boolean { + if (block.type === TRIGGER_TYPES.CHAT) { + return true + } + + // Legacy: starter block in chat mode + if (block.type === TRIGGER_TYPES.STARTER) { + return block.subBlocks?.startWorkflow?.value === 'chat' + } + + return false + } + + /** + * Check if a block is a manual-compatible trigger + */ + static isManualTrigger(block: { type: string; subBlocks?: any }): boolean { + if (block.type === TRIGGER_TYPES.INPUT) { + return true + } + + // Legacy: starter block in manual mode or without explicit mode (default to manual) + if (block.type === TRIGGER_TYPES.STARTER) { + // If startWorkflow is not set or is set to 'manual', treat as manual trigger + const startWorkflowValue = block.subBlocks?.startWorkflow?.value + return startWorkflowValue === 'manual' || startWorkflowValue === undefined + } + + return false + } + + /** + * Check if a block is an API-compatible trigger + * @param block - Block to check + * @param isChildWorkflow - Whether this is being called from a child workflow context + */ + static isApiTrigger(block: { type: string; subBlocks?: any }, isChildWorkflow = false): boolean { + if (isChildWorkflow) { + // Child workflows (workflow-in-workflow) only work with input_trigger + return block.type === TRIGGER_TYPES.INPUT + } + // Direct API calls only work with api_trigger + if (block.type === TRIGGER_TYPES.API) { + return true + } + + // Legacy: starter block in API mode + if (block.type === TRIGGER_TYPES.STARTER) { + const mode = block.subBlocks?.startWorkflow?.value + return mode === 'api' || mode === 'run' + } + + return false + } + + /** + * Get the default name for a trigger type + */ + static getDefaultTriggerName(triggerType: string): string | null { + // Use the block's actual name from the registry + const block = getBlock(triggerType) + if (block) { + // Special case for generic_webhook - show as "Webhook" in UI + if (triggerType === 'generic_webhook') { + return 'Webhook' + } + return block.name + } + + // Fallback for legacy or unknown types + switch (triggerType) { + case TRIGGER_TYPES.CHAT: + return 'Chat' + case TRIGGER_TYPES.INPUT: + return 'Input Trigger' + case TRIGGER_TYPES.API: + return 'API' + case TRIGGER_TYPES.WEBHOOK: + return 'Webhook' + case TRIGGER_TYPES.SCHEDULE: + return 'Schedule' + default: + return null + } + } + + /** + * Find trigger blocks of a specific type in a workflow + */ + static findTriggersByType( + blocks: T[] | Record, + triggerType: 'chat' | 'manual' | 'api', + isChildWorkflow = false + ): T[] { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + + switch (triggerType) { + case 'chat': + return blockArray.filter((block) => TriggerUtils.isChatTrigger(block)) + case 'manual': + return blockArray.filter((block) => TriggerUtils.isManualTrigger(block)) + case 'api': + return blockArray.filter((block) => TriggerUtils.isApiTrigger(block, isChildWorkflow)) + default: + return [] + } + } + + /** + * Find the appropriate start block for a given execution context + */ + static findStartBlock( + blocks: Record, + executionType: 'chat' | 'manual' | 'api', + isChildWorkflow = false + ): { blockId: string; block: T } | null { + const entries = Object.entries(blocks) + + // Look for new trigger blocks first + const triggers = TriggerUtils.findTriggersByType(blocks, executionType, isChildWorkflow) + if (triggers.length > 0) { + const blockId = entries.find(([, b]) => b === triggers[0])?.[0] + if (blockId) { + return { blockId, block: triggers[0] } + } + } + + // Legacy fallback: look for starter block + const starterEntry = entries.find(([, block]) => block.type === TRIGGER_TYPES.STARTER) + if (starterEntry) { + return { blockId: starterEntry[0], block: starterEntry[1] } + } + + return null + } + + /** + * Check if multiple triggers of a restricted type exist + */ + static hasMultipleTriggers( + blocks: T[] | Record, + triggerType: TriggerType + ): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + const count = blockArray.filter((block) => block.type === triggerType).length + return count > 1 + } + + /** + * Check if a trigger type requires single instance constraint + */ + static requiresSingleInstance(triggerType: string): boolean { + // API and Input triggers cannot coexist with each other + // Chat trigger must be unique + // Schedules and webhooks can coexist with API/Input triggers + return ( + triggerType === TRIGGER_TYPES.API || + triggerType === TRIGGER_TYPES.INPUT || + triggerType === TRIGGER_TYPES.CHAT + ) + } + + /** + * Check if a workflow has a legacy starter block + */ + static hasLegacyStarter(blocks: T[] | Record): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + return blockArray.some((block) => block.type === TRIGGER_TYPES.STARTER) + } + + /** + * Check if adding a trigger would violate single instance constraint + */ + static wouldViolateSingleInstance( + blocks: T[] | Record, + triggerType: string + ): boolean { + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + const hasLegacyStarter = TriggerUtils.hasLegacyStarter(blocks) + + // Legacy starter block can't coexist with Chat, Input, or API triggers + if (hasLegacyStarter) { + if ( + triggerType === TRIGGER_TYPES.CHAT || + triggerType === TRIGGER_TYPES.INPUT || + triggerType === TRIGGER_TYPES.API + ) { + return true + } + } + + if (triggerType === TRIGGER_TYPES.STARTER) { + const hasModernTriggers = blockArray.some( + (block) => + block.type === TRIGGER_TYPES.CHAT || + block.type === TRIGGER_TYPES.INPUT || + block.type === TRIGGER_TYPES.API + ) + if (hasModernTriggers) { + return true + } + } + + // Only one Input trigger allowed + if (triggerType === TRIGGER_TYPES.INPUT) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.INPUT) + } + + // Only one API trigger allowed + if (triggerType === TRIGGER_TYPES.API) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.API) + } + + // Chat trigger must be unique + if (triggerType === TRIGGER_TYPES.CHAT) { + return blockArray.some((block) => block.type === TRIGGER_TYPES.CHAT) + } + + // Centralized rule: only API, Input, Chat are single-instance + if (!TriggerUtils.requiresSingleInstance(triggerType)) { + return false + } + + return blockArray.some((block) => block.type === triggerType) + } + + /** + * Evaluate whether adding a trigger of the given type is allowed and, if not, why. + * Returns null if allowed; otherwise returns an object describing the violation. + * This avoids duplicating UI logic across toolbar/drop handlers. + */ + static getTriggerAdditionIssue( + blocks: T[] | Record, + triggerType: string + ): { issue: 'legacy' | 'duplicate'; triggerName: string } | null { + if (!TriggerUtils.wouldViolateSingleInstance(blocks, triggerType)) { + return null + } + + // Legacy starter present + adding modern trigger → legacy incompatibility + if (TriggerUtils.hasLegacyStarter(blocks) && TriggerUtils.isAnyTriggerType(triggerType)) { + return { issue: 'legacy', triggerName: 'new trigger' } + } + + // Otherwise treat as duplicate of a single-instance trigger + const triggerName = TriggerUtils.getDefaultTriggerName(triggerType) || 'trigger' + return { issue: 'duplicate', triggerName } + } + + /** + * Get trigger validation message + */ + static getTriggerValidationMessage( + triggerType: 'chat' | 'manual' | 'api', + issue: 'missing' | 'multiple' + ): string { + const triggerName = triggerType.charAt(0).toUpperCase() + triggerType.slice(1) + + if (issue === 'missing') { + return `${triggerName} execution requires a ${triggerName} Trigger block` + } + + return `Multiple ${triggerName} Trigger blocks found. Keep only one.` + } +} diff --git a/apps/sim/lib/workflows/validation.ts b/apps/sim/lib/workflows/validation.ts index e09abf3aeb..f92fc16c40 100644 --- a/apps/sim/lib/workflows/validation.ts +++ b/apps/sim/lib/workflows/validation.ts @@ -1,4 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' +import { getBlock } from '@/blocks/registry' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { getTool } from '@/tools/utils' const logger = createLogger('WorkflowValidation') @@ -107,3 +110,165 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { return { blocks: sanitizedBlocks, warnings } } + +export interface WorkflowValidationResult { + valid: boolean + errors: string[] + warnings: string[] + sanitizedState?: WorkflowState +} + +/** + * Comprehensive workflow state validation + * Checks all tool references, block types, and required fields + */ +export function validateWorkflowState( + workflowState: WorkflowState, + options: { sanitize?: boolean } = {} +): WorkflowValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + let sanitizedState = workflowState + + try { + // Basic structure validation + if (!workflowState || typeof workflowState !== 'object') { + errors.push('Invalid workflow state: must be an object') + return { valid: false, errors, warnings } + } + + if (!workflowState.blocks || typeof workflowState.blocks !== 'object') { + errors.push('Invalid workflow state: missing blocks') + return { valid: false, errors, warnings } + } + + // Validate each block + const sanitizedBlocks: Record = {} + let hasChanges = false + + for (const [blockId, block] of Object.entries(workflowState.blocks)) { + if (!block || typeof block !== 'object') { + errors.push(`Block ${blockId}: invalid block structure`) + continue + } + + // Check if block type exists + const blockConfig = getBlock(block.type) + if (!blockConfig) { + errors.push(`Block ${block.name || blockId}: unknown block type '${block.type}'`) + if (options.sanitize) { + hasChanges = true + continue // Skip this block in sanitized output + } + } + + // Validate tool references in blocks that use tools + if (block.type === 'api' || block.type === 'generic') { + // For API and generic blocks, the tool is determined by the block's tool configuration + // In the workflow state, we need to check if the block type has valid tool access + const blockConfig = getBlock(block.type) + if (blockConfig?.tools?.access) { + // API block has static tool access + const toolIds = blockConfig.tools.access + for (const toolId of toolIds) { + const validationError = validateToolReference(toolId, block.type, block.name) + if (validationError) { + errors.push(validationError) + } + } + } + } else if (block.type === 'knowledge' || block.type === 'supabase' || block.type === 'mcp') { + // These blocks have dynamic tool selection based on operation + // The actual tool validation happens at runtime based on the operation value + // For now, just ensure the block type is valid (already checked above) + } + + // Special validation for agent blocks + if (block.type === 'agent' && block.subBlocks?.tools?.value) { + const toolsSanitization = sanitizeAgentToolsInBlocks({ [blockId]: block }) + warnings.push(...toolsSanitization.warnings) + if (toolsSanitization.warnings.length > 0) { + sanitizedBlocks[blockId] = toolsSanitization.blocks[blockId] + hasChanges = true + } else { + sanitizedBlocks[blockId] = block + } + } else { + sanitizedBlocks[blockId] = block + } + } + + // Validate edges reference existing blocks + if (workflowState.edges && Array.isArray(workflowState.edges)) { + const blockIds = new Set(Object.keys(sanitizedBlocks)) + const loopIds = new Set(Object.keys(workflowState.loops || {})) + const parallelIds = new Set(Object.keys(workflowState.parallels || {})) + + for (const edge of workflowState.edges) { + if (!edge || typeof edge !== 'object') { + errors.push('Invalid edge structure') + continue + } + + // Check if source and target exist + const sourceExists = + blockIds.has(edge.source) || loopIds.has(edge.source) || parallelIds.has(edge.source) + const targetExists = + blockIds.has(edge.target) || loopIds.has(edge.target) || parallelIds.has(edge.target) + + if (!sourceExists) { + errors.push(`Edge references non-existent source block '${edge.source}'`) + } + if (!targetExists) { + errors.push(`Edge references non-existent target block '${edge.target}'`) + } + } + } + + // If we made changes during sanitization, create a new state object + if (hasChanges && options.sanitize) { + sanitizedState = { + ...workflowState, + blocks: sanitizedBlocks, + } + } + + const valid = errors.length === 0 + return { + valid, + errors, + warnings, + sanitizedState: options.sanitize ? sanitizedState : undefined, + } + } catch (err) { + logger.error('Workflow validation failed with exception', err) + errors.push(`Validation failed: ${err instanceof Error ? err.message : String(err)}`) + return { valid: false, errors, warnings } + } +} + +/** + * Validate tool reference for a specific block + * Returns null if valid, error message if invalid + */ +export function validateToolReference( + toolId: string | undefined, + blockType: string, + blockName?: string +): string | null { + if (!toolId) return null + + // Check if it's a custom tool or MCP tool + const isCustomTool = toolId.startsWith('custom_') + const isMcpTool = toolId.startsWith('mcp-') + + if (!isCustomTool && !isMcpTool) { + // For built-in tools, verify they exist + const tool = getTool(toolId) + if (!tool) { + return `Block ${blockName || 'unknown'} (${blockType}): references non-existent tool '${toolId}'` + } + } + + return null +} diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 37907303e5..ba5a65e2a5 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -417,8 +417,12 @@ export class Serializer { blockConfig: any, params: Record ) { - // Skip validation if the block is in trigger mode - if (block.triggerMode || blockConfig.category === 'triggers') { + // Skip validation if the block is used as a trigger + if ( + block.triggerMode === true || + blockConfig.category === 'triggers' || + params.triggerMode === true + ) { logger.info('Skipping validation for block in trigger mode', { blockId: block.id, blockType: block.type, diff --git a/apps/sim/socket-server/database/operations.ts b/apps/sim/socket-server/database/operations.ts index a32ca1bc4b..9b37207251 100644 --- a/apps/sim/socket-server/database/operations.ts +++ b/apps/sim/socket-server/database/operations.ts @@ -234,6 +234,8 @@ async function handleBlockOperationTx( throw new Error('Missing required fields for add block operation') } + // Note: single-API-trigger enforcement is handled client-side to avoid disconnects + logger.debug(`[SERVER] Adding block: ${payload.type} (${payload.id})`, { isSubflowType: isSubflowBlockType(payload.type), }) @@ -268,6 +270,7 @@ async function handleBlockOperationTx( horizontalHandles: payload.horizontalHandles ?? true, isWide: payload.isWide ?? false, advancedMode: payload.advancedMode ?? false, + triggerMode: payload.triggerMode ?? false, height: payload.height || 0, } @@ -660,6 +663,7 @@ async function handleBlockOperationTx( horizontalHandles: payload.horizontalHandles ?? true, isWide: payload.isWide ?? false, advancedMode: payload.advancedMode ?? false, + triggerMode: payload.triggerMode ?? false, height: payload.height || 0, } diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 3fc7553bf6..a3fea5c4f4 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -10,6 +10,9 @@ import type { import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { GetBlocksAndToolsClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-and-tools' import { GetBlocksMetadataClientTool } from '@/lib/copilot/tools/client/blocks/get-blocks-metadata' +import { GetTriggerBlocksClientTool } from '@/lib/copilot/tools/client/blocks/get-trigger-blocks' +import { GetExamplesRagClientTool } from '@/lib/copilot/tools/client/examples/get-examples-rag' +import { GetTriggerExamplesClientTool } from '@/lib/copilot/tools/client/examples/get-trigger-examples' import { ListGDriveFilesClientTool } from '@/lib/copilot/tools/client/gdrive/list-files' import { ReadGDriveFileClientTool } from '@/lib/copilot/tools/client/gdrive/read-file' import { GDriveRequestAccessClientTool } from '@/lib/copilot/tools/client/google/gdrive-request-access' @@ -66,6 +69,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id), get_blocks_and_tools: (id) => new GetBlocksAndToolsClientTool(id), get_blocks_metadata: (id) => new GetBlocksMetadataClientTool(id), + get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id), search_online: (id) => new SearchOnlineClientTool(id), search_documentation: (id) => new SearchDocumentationClientTool(id), get_environment_variables: (id) => new GetEnvironmentVariablesClientTool(id), @@ -86,6 +90,8 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = { get_workflow_from_name: (id) => new GetWorkflowFromNameClientTool(id), get_global_workflow_variables: (id) => new GetGlobalWorkflowVariablesClientTool(id), set_global_workflow_variables: (id) => new SetGlobalWorkflowVariablesClientTool(id), + get_trigger_examples: (id) => new GetTriggerExamplesClientTool(id), + get_examples_rag: (id) => new GetExamplesRagClientTool(id), } // Read-only static metadata for class-based tools (no instances) @@ -94,6 +100,7 @@ export const CLASS_TOOL_METADATA: Record()( ...initialState, workflowId, mode: get().mode, - agentDepth: get().agentDepth, + selectedModel: get().selectedModel, agentPrefetch: get().agentPrefetch, }) }, @@ -1539,16 +1549,9 @@ export const useCopilotStore = create()( } const isFirstMessage = get().messages.length === 0 && !currentChat?.title - // Capture send-time meta for reliable stats - const sendDepth = get().agentDepth - const sendMaxEnabled = sendDepth >= 2 && !get().agentPrefetch set((state) => ({ messages: newMessages, currentUserMessageId: userMessage.id, - messageMetaById: { - ...(state.messageMetaById || {}), - [userMessage.id]: { depth: sendDepth, maxEnabled: sendMaxEnabled }, - }, })) if (isFirstMessage) { @@ -1583,7 +1586,7 @@ export const useCopilotStore = create()( chatId: currentChat?.id, workflowId, mode: mode === 'ask' ? 'ask' : 'agent', - depth: get().agentDepth, + model: get().selectedModel, prefetch: get().agentPrefetch, createNewChat: !currentChat, stream, @@ -1616,6 +1619,11 @@ export const useCopilotStore = create()( } else if (result.status === 403) { errorContent = '_Provider config not allowed for non-enterprise users. Please remove the provider config and try again_' + } else if (result.status === 426) { + errorContent = + '_Please upgrade to the latest version of the Sim platform to continue using the copilot._' + } else if (result.status === 429) { + errorContent = '_Provider rate limit exceeded. Please try again later._' } const errorMessage = createErrorMessage(streamingMessage.id, errorContent) @@ -1692,7 +1700,7 @@ export const useCopilotStore = create()( // Implicit feedback (send a continuation) - minimal sendImplicitFeedback: async (implicitFeedback: string) => { - const { workflowId, currentChat, mode, agentDepth } = get() + const { workflowId, currentChat, mode, selectedModel } = get() if (!workflowId) return const abortController = new AbortController() set({ isSendingMessage: true, error: null, abortController }) @@ -1704,7 +1712,7 @@ export const useCopilotStore = create()( chatId: currentChat?.id, workflowId, mode: mode === 'ask' ? 'ask' : 'agent', - depth: agentDepth, + model: selectedModel, prefetch: get().agentPrefetch, createNewChat: !currentChat, stream: true, @@ -2177,7 +2185,7 @@ export const useCopilotStore = create()( updateDiffStore: async (_yamlContent: string) => {}, updateDiffStoreWithWorkflowState: async (_workflowState: any) => {}, - setAgentDepth: (depth) => set({ agentDepth: depth }), + setSelectedModel: (model) => set({ selectedModel: model }), setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), })) ) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 4b0ce2eb03..de0de48150 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -68,8 +68,18 @@ export type CopilotMode = 'ask' | 'agent' export interface CopilotState { mode: CopilotMode - agentDepth: 0 | 1 | 2 | 3 + selectedModel: + | 'gpt-5-fast' + | 'gpt-5' + | 'gpt-5-medium' + | 'gpt-5-high' + | 'gpt-4o' + | 'gpt-4.1' + | 'o3' + | 'claude-4-sonnet' + | 'claude-4.1-opus' agentPrefetch: boolean + isCollapsed: boolean currentChat: CopilotChat | null chats: CopilotChat[] @@ -112,12 +122,11 @@ export interface CopilotState { currentUserMessageId?: string | null // Per-message metadata captured at send-time for reliable stats - messageMetaById?: Record } export interface CopilotActions { setMode: (mode: CopilotMode) => void - setAgentDepth: (depth: 0 | 1 | 2 | 3) => void + setSelectedModel: (model: CopilotStore['selectedModel']) => void setAgentPrefetch: (prefetch: boolean) => void setWorkflowId: (workflowId: string | null) => Promise diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 61dfc3744c..736ca6b571 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -3,6 +3,7 @@ import { devtools } from 'zustand/middleware' import { getClientTool } from '@/lib/copilot/tools/client/manager' import { createLogger } from '@/lib/logs/console/logger' import { type DiffAnalysis, WorkflowDiffEngine } from '@/lib/workflows/diff' +import { validateWorkflowState } from '@/lib/workflows/validation' import { Serializer } from '@/serializer' import { useWorkflowRegistry } from '../workflows/registry/store' import { useSubBlockStore } from '../workflows/subblock/store' @@ -293,6 +294,36 @@ export const useWorkflowDiffStore = create 0) { + logger.warn('Workflow validation warnings during diff acceptance', { + warnings: validation.warnings, + }) + } + // Immediately flag diffAccepted on stats if we can (early upsert with minimal fields) try { const { useCopilotStore } = await import('@/stores/copilot/store') @@ -313,19 +344,19 @@ export const useWorkflowDiffStore = create> = {} - Object.entries(cleanState.blocks).forEach(([blockId, block]) => { + Object.entries(stateToApply.blocks).forEach(([blockId, block]) => { subblockValues[blockId] = {} Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value + subblockValues[blockId][subblockId] = subblock.value }) }) diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 56cf71f553..bf6e616968 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -12,7 +12,6 @@ import type { import { getNextWorkflowColor } from '@/stores/workflows/registry/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowRegistry') @@ -641,104 +640,9 @@ export const useWorkflowRegistry = create()( logger.info(`Created workflow from marketplace: ${options.marketplaceId}`) } else { - // Create starter block for new workflow - const starterId = crypto.randomUUID() - const starterBlock = { - id: starterId, - type: 'starter' as const, - name: 'Start', - position: { x: 100, y: 100 }, - subBlocks: { - startWorkflow: { - id: 'startWorkflow', - type: 'dropdown' as const, - value: 'manual', - }, - webhookPath: { - id: 'webhookPath', - type: 'short-input' as const, - value: '', - }, - webhookSecret: { - id: 'webhookSecret', - type: 'short-input' as const, - value: '', - }, - scheduleType: { - id: 'scheduleType', - type: 'dropdown' as const, - value: 'daily', - }, - minutesInterval: { - id: 'minutesInterval', - type: 'short-input' as const, - value: '', - }, - minutesStartingAt: { - id: 'minutesStartingAt', - type: 'short-input' as const, - value: '', - }, - hourlyMinute: { - id: 'hourlyMinute', - type: 'short-input' as const, - value: '', - }, - dailyTime: { - id: 'dailyTime', - type: 'short-input' as const, - value: '', - }, - weeklyDay: { - id: 'weeklyDay', - type: 'dropdown' as const, - value: 'MON', - }, - weeklyDayTime: { - id: 'weeklyDayTime', - type: 'short-input' as const, - value: '', - }, - monthlyDay: { - id: 'monthlyDay', - type: 'short-input' as const, - value: '', - }, - monthlyTime: { - id: 'monthlyTime', - type: 'short-input' as const, - value: '', - }, - cronExpression: { - id: 'cronExpression', - type: 'short-input' as const, - value: '', - }, - timezone: { - id: 'timezone', - type: 'dropdown' as const, - value: 'UTC', - }, - }, - outputs: { - response: { - type: { - input: 'any', - }, - }, - }, - enabled: true, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: 0, - } - + // Create empty workflow (no default blocks) initialState = { - blocks: { - [starterId]: starterBlock, - }, + blocks: {}, edges: [], loops: {}, parallels: {}, @@ -750,9 +654,7 @@ export const useWorkflowRegistry = create()( past: [], present: { state: { - blocks: { - [starterId]: starterBlock, - }, + blocks: {}, edges: [], loops: {}, parallels: {}, @@ -788,15 +690,8 @@ export const useWorkflowRegistry = create()( // Initialize subblock values to ensure they're available for sync if (!options.marketplaceId) { - // For non-marketplace workflows, initialize subblock values from the starter block + // For non-marketplace workflows, initialize empty subblock values const subblockValues: Record> = {} - const blocks = initialState.blocks as Record - for (const [blockId, block] of Object.entries(blocks)) { - subblockValues[blockId] = {} - for (const [subblockId, subblock] of Object.entries(block.subBlocks)) { - subblockValues[blockId][subblockId] = (subblock as any).value - } - } // Update the subblock store with the initial values useSubBlockStore.setState((state) => ({ diff --git a/apps/sim/stores/workflows/yaml/importer.ts b/apps/sim/stores/workflows/yaml/importer.ts index c31d16dbdd..474c1c5bfb 100644 --- a/apps/sim/stores/workflows/yaml/importer.ts +++ b/apps/sim/stores/workflows/yaml/importer.ts @@ -123,6 +123,11 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war const errors: string[] = [] const warnings: string[] = [] + // Precompute counts that are used in validations to avoid O(n^2) checks + const apiTriggerCount = Object.values(yamlWorkflow.blocks).filter( + (b) => b.type === 'api_trigger' + ).length + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => { // Use shared structure validation const { errors: structureErrors, warnings: structureWarnings } = validateBlockStructure( @@ -159,6 +164,11 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war } }) + // Enforce only one API trigger in YAML (single check outside the loop) + if (apiTriggerCount > 1) { + errors.push('Only one API trigger is allowed per workflow (YAML contains multiple).') + } + return { errors, warnings } } diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 50fbc7aa55..dd3ebcdb0a 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -130,131 +130,143 @@ export function getToolParametersConfig( toolId: string, blockType?: string ): ToolWithParameters | null { - const toolConfig = getTool(toolId) - if (!toolConfig) { - return null - } + try { + const toolConfig = getTool(toolId) + if (!toolConfig) { + console.warn(`Tool not found: ${toolId}`) + return null + } - // Get block configuration for UI component information - let blockConfig: BlockConfig | null = null - if (blockType) { - const blockConfigs = getBlockConfigurations() - blockConfig = blockConfigs[blockType] || null - } + // Validate that toolConfig has required properties + if (!toolConfig.params || typeof toolConfig.params !== 'object') { + console.warn(`Tool ${toolId} has invalid params configuration`) + return null + } - // Convert tool params to our standard format with UI component info - const allParameters: ToolParameterConfig[] = Object.entries(toolConfig.params).map( - ([paramId, param]) => { - const toolParam: ToolParameterConfig = { - id: paramId, - type: param.type, - required: param.required ?? false, - visibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'), - description: param.description, - default: param.default, - } + // Get block configuration for UI component information + let blockConfig: BlockConfig | null = null + if (blockType) { + const blockConfigs = getBlockConfigurations() + blockConfig = blockConfigs[blockType] || null + } - // Add UI component information from block config if available - if (blockConfig) { - // For multi-operation tools, find the subblock that matches both the parameter ID - // and the current tool operation - let subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => { - if (sb.id !== paramId) return false - - // If there's a condition, check if it matches the current tool - if (sb.condition && sb.condition.field === 'operation') { - // First try exact match with full tool ID - if (sb.condition.value === toolId) return true - - // Then try extracting operation from tool ID - // For tools like 'google_calendar_quick_add', extract 'quick_add' - const parts = toolId.split('_') - if (parts.length >= 3) { - // Join everything after the provider prefix (e.g., 'google_calendar_') - const operation = parts.slice(2).join('_') - if (sb.condition.value === operation) return true - } + // Convert tool params to our standard format with UI component info + const allParameters: ToolParameterConfig[] = Object.entries(toolConfig.params).map( + ([paramId, param]) => { + const toolParam: ToolParameterConfig = { + id: paramId, + type: param.type, + required: param.required ?? false, + visibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'), + description: param.description, + default: param.default, + } - // Fallback to last part only - const operation = parts[parts.length - 1] - return sb.condition.value === operation - } + // Add UI component information from block config if available + if (blockConfig) { + // For multi-operation tools, find the subblock that matches both the parameter ID + // and the current tool operation + let subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => { + if (sb.id !== paramId) return false + + // If there's a condition, check if it matches the current tool + if (sb.condition && sb.condition.field === 'operation') { + // First try exact match with full tool ID + if (sb.condition.value === toolId) return true + + // Then try extracting operation from tool ID + // For tools like 'google_calendar_quick_add', extract 'quick_add' + const parts = toolId.split('_') + if (parts.length >= 3) { + // Join everything after the provider prefix (e.g., 'google_calendar_') + const operation = parts.slice(2).join('_') + if (sb.condition.value === operation) return true + } + + // Fallback to last part only + const operation = parts[parts.length - 1] + return sb.condition.value === operation + } - // If no condition, it's a global parameter (like apiKey) - return !sb.condition - }) + // If no condition, it's a global parameter (like apiKey) + return !sb.condition + }) - // Fallback: if no operation-specific match, find any matching parameter - if (!subBlock) { - subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => sb.id === paramId) - } + // Fallback: if no operation-specific match, find any matching parameter + if (!subBlock) { + subBlock = blockConfig.subBlocks?.find((sb: SubBlockConfig) => sb.id === paramId) + } - // Special case: Check if this boolean parameter is part of a checkbox-list - if (!subBlock && param.type === 'boolean' && blockConfig) { - // Look for a checkbox-list that includes this parameter as an option - const checkboxListBlock = blockConfig.subBlocks?.find( - (sb: SubBlockConfig) => - sb.type === 'checkbox-list' && - Array.isArray(sb.options) && - sb.options.some((opt: any) => opt.id === paramId) - ) - - if (checkboxListBlock) { - subBlock = checkboxListBlock + // Special case: Check if this boolean parameter is part of a checkbox-list + if (!subBlock && param.type === 'boolean' && blockConfig) { + // Look for a checkbox-list that includes this parameter as an option + const checkboxListBlock = blockConfig.subBlocks?.find( + (sb: SubBlockConfig) => + sb.type === 'checkbox-list' && + Array.isArray(sb.options) && + sb.options.some((opt: any) => opt.id === paramId) + ) + + if (checkboxListBlock) { + subBlock = checkboxListBlock + } } - } - if (subBlock) { - toolParam.uiComponent = { - type: subBlock.type, - options: subBlock.options, - placeholder: subBlock.placeholder, - password: subBlock.password, - condition: subBlock.condition, - title: subBlock.title, - layout: subBlock.layout, - value: subBlock.value, - provider: subBlock.provider, - serviceId: subBlock.serviceId, - requiredScopes: subBlock.requiredScopes, - mimeType: subBlock.mimeType, - columns: subBlock.columns, - min: subBlock.min, - max: subBlock.max, - step: subBlock.step, - integer: subBlock.integer, - language: subBlock.language, - generationType: subBlock.generationType, - acceptedTypes: subBlock.acceptedTypes, - multiple: subBlock.multiple, - maxSize: subBlock.maxSize, + if (subBlock) { + toolParam.uiComponent = { + type: subBlock.type, + options: subBlock.options, + placeholder: subBlock.placeholder, + password: subBlock.password, + condition: subBlock.condition, + title: subBlock.title, + layout: subBlock.layout, + value: subBlock.value, + provider: subBlock.provider, + serviceId: subBlock.serviceId, + requiredScopes: subBlock.requiredScopes, + mimeType: subBlock.mimeType, + columns: subBlock.columns, + min: subBlock.min, + max: subBlock.max, + step: subBlock.step, + integer: subBlock.integer, + language: subBlock.language, + generationType: subBlock.generationType, + acceptedTypes: subBlock.acceptedTypes, + multiple: subBlock.multiple, + maxSize: subBlock.maxSize, + } } } - } - return toolParam - } - ) + return toolParam + } + ) - // Parameters that should be shown to the user for input - const userInputParameters = allParameters.filter( - (param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only' - ) + // Parameters that should be shown to the user for input + const userInputParameters = allParameters.filter( + (param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only' + ) - // Parameters that are required (must be filled by user or LLM) - const requiredParameters = allParameters.filter((param) => param.required) + // Parameters that are required (must be filled by user or LLM) + const requiredParameters = allParameters.filter((param) => param.required) - // Parameters that are optional but can be provided by user - const optionalParameters = allParameters.filter( - (param) => param.visibility === 'user-only' && !param.required - ) + // Parameters that are optional but can be provided by user + const optionalParameters = allParameters.filter( + (param) => param.visibility === 'user-only' && !param.required + ) - return { - toolConfig, - allParameters, - userInputParameters, - requiredParameters, - optionalParameters, + return { + toolConfig, + allParameters, + userInputParameters, + requiredParameters, + optionalParameters, + } + } catch (error) { + console.error('Error getting tool parameters config:', error) + return null } }