diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index feac645b6ed..876fb6ad018 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -263,3 +263,10 @@ export const SlackIcon = (props: SVGProps) => ( ) + +export const ResponseIcon = (props: SVGProps) => ( + + + + +) diff --git a/apps/docs/components/ui/block-types.tsx b/apps/docs/components/ui/block-types.tsx index 1b2394666e7..96937dc728a 100644 --- a/apps/docs/components/ui/block-types.tsx +++ b/apps/docs/components/ui/block-types.tsx @@ -1,5 +1,13 @@ import { cn } from '@/lib/utils' -import { AgentIcon, ApiIcon, ChartBarIcon, CodeIcon, ConditionalIcon, ConnectIcon } from '../icons' +import { + AgentIcon, + ApiIcon, + ChartBarIcon, + CodeIcon, + ConditionalIcon, + ConnectIcon, + ResponseIcon, +} from '../icons' // Custom Feature component specifically for BlockTypes to handle the 6-item layout const BlockFeature = ({ @@ -127,6 +135,13 @@ export function BlockTypes() { icon: , href: '/blocks/evaluator', }, + { + title: 'Response', + description: + 'Send a response back to the caller with customizable data, status, and headers.', + icon: , + href: '/blocks/response', + }, ] const totalItems = features.length diff --git a/apps/docs/content/docs/blocks/meta.json b/apps/docs/content/docs/blocks/meta.json index b8bfa7fa993..98a69a80e8a 100644 --- a/apps/docs/content/docs/blocks/meta.json +++ b/apps/docs/content/docs/blocks/meta.json @@ -1,4 +1,4 @@ { "title": "Blocks", - "pages": ["agent", "api", "condition", "function", "evaluator", "router", "workflow"] + "pages": ["agent", "api", "condition", "function", "evaluator", "router", "response", "workflow"] } diff --git a/apps/docs/content/docs/blocks/response.mdx b/apps/docs/content/docs/blocks/response.mdx new file mode 100644 index 00000000000..2570acd872b --- /dev/null +++ b/apps/docs/content/docs/blocks/response.mdx @@ -0,0 +1,188 @@ +--- +title: Response +description: Send a structured response back to API calls +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { ThemeImage } from '@/components/ui/theme-image' + +The Response block is the final component in API-enabled workflows that transforms your workflow's variables into a structured HTTP response. This block serves as the endpoint that returns data, status codes, and headers back to API callers. + + + + + Response blocks are terminal blocks - they mark the end of a workflow execution and cannot have further connections. + + +## Overview + +The Response block serves as the final output mechanism for API workflows, enabling you to: + + + + Return structured data: Transform workflow variables into JSON responses + + + Set HTTP status codes: Control the response status (200, 400, 500, etc.) + + + Configure headers: Add custom HTTP headers to the response + + + Reference variables: Use workflow variables dynamically in the response + + + +## Configuration Options + +### Response Data + +The response data is the main content that will be sent back to the API caller. This should be formatted as JSON and can include: + +- Static values +- Dynamic references to workflow variables using the `` syntax +- Nested objects and arrays +- Any valid JSON structure + +### Status Code + +Set the HTTP status code for the response. Common status codes include: + + + +
    +
  • 200: OK - Standard success response
  • +
  • 201: Created - Resource successfully created
  • +
  • 204: No Content - Success with no response body
  • +
+
+ +
    +
  • 400: Bad Request - Invalid request parameters
  • +
  • 401: Unauthorized - Authentication required
  • +
  • 404: Not Found - Resource doesn't exist
  • +
  • 422: Unprocessable Entity - Validation errors
  • +
+
+ +
    +
  • 500: Internal Server Error - Server-side error
  • +
  • 502: Bad Gateway - External service error
  • +
  • 503: Service Unavailable - Service temporarily down
  • +
+
+
+ +

+ Default status code is 200 if not specified. +

+ +### Response Headers + +Configure additional HTTP headers to include in the response. + +Headers are configured as key-value pairs: + +| Key | Value | +|-----|-------| +| Content-Type | application/json | +| Cache-Control | no-cache | +| X-API-Version | 1.0 | + +## Inputs and Outputs + + + +
    +
  • + data (JSON, optional): The JSON data to send in the response body +
  • +
  • + status (number, optional): HTTP status code (default: 200) +
  • +
  • + headers (JSON, optional): Additional response headers +
  • +
+
+ +
    +
  • + response: Complete response object containing: +
      +
    • data: The response body data
    • +
    • status: HTTP status code
    • +
    • headers: Response headers
    • +
    +
  • +
+
+
+ +## Variable References + +Use the `` syntax to dynamically insert workflow variables into your response: + +```json +{ + "user": { + "id": "", + "name": "", + "email": "" + }, + "query": "", + "results": "", + "totalFound": "", + "processingTime": "ms" +} +``` + + + Variable names are case-sensitive and must match exactly with the variables available in your workflow. + + +## Example Usage + +Here's an example of how a Response block might be configured for a user search API: + +```yaml +data: | + { + "success": true, + "data": { + "users": "", + "pagination": { + "page": "", + "limit": "", + "total": "" + } + }, + "query": { + "searchTerm": "", + "filters": "" + }, + "timestamp": "" + } +status: 200 +headers: + - key: X-Total-Count + value: + - key: Cache-Control + value: public, max-age=300 +``` + +## Best Practices + +- **Use meaningful status codes**: Choose appropriate HTTP status codes that accurately reflect the outcome of the workflow +- **Structure your responses consistently**: Maintain a consistent JSON structure across all your API endpoints for better developer experience +- **Include relevant metadata**: Add timestamps and version information to help with debugging and monitoring +- **Handle errors gracefully**: Use conditional logic in your workflow to set appropriate error responses with descriptive messages +- **Validate variable references**: Ensure all referenced variables exist and contain the expected data types before the Response block executes \ No newline at end of file diff --git a/apps/docs/public/static/dark/response-dark.png b/apps/docs/public/static/dark/response-dark.png new file mode 100644 index 00000000000..e52919887de Binary files /dev/null and b/apps/docs/public/static/dark/response-dark.png differ diff --git a/apps/docs/public/static/light/response-light.png b/apps/docs/public/static/light/response-light.png new file mode 100644 index 00000000000..4503b967f18 Binary files /dev/null and b/apps/docs/public/static/light/response-light.png differ diff --git a/apps/sim/app/api/codegen/route.ts b/apps/sim/app/api/codegen/route.ts index 557204c97bb..49aabdaec5e 100644 --- a/apps/sim/app/api/codegen/route.ts +++ b/apps/sim/app/api/codegen/route.ts @@ -25,6 +25,7 @@ type GenerationType = | 'javascript-function-body' | 'typescript-function-body' | 'custom-tool-schema' + | 'json-object' // Define the structure for a single message in the history interface ChatMessage { @@ -281,6 +282,24 @@ if (!response.ok) { const data: unknown = await response.json() // Add type checking/assertion if necessary return data // Ensure you return a value if expected`, + + 'json-object': `You are an expert JSON programmer. +Generate ONLY the raw JSON object based on the user's request. +The output MUST be a single, valid JSON object, starting with { and ending with }. + +Do not include any explanations, markdown formatting, or other text outside the JSON object. + +You have access to the following variables you can use to generate the JSON body: +- 'params' (object): Contains input parameters derived from the JSON schema. Access these directly using the parameter name wrapped in angle brackets, e.g., ''. Do NOT use 'params.paramName'. +- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env. + +Example: +{ + "name": "", + "age": , + "success": true +} +`, } export async function POST(req: NextRequest) { 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 e511faa223c..7b673364af3 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.test.ts @@ -109,6 +109,7 @@ describe('Workflow Execution API Route', () => { // Mock workflow run counts vi.doMock('@/lib/workflows/utils', () => ({ updateWorkflowRunCounts: vi.fn().mockResolvedValue(undefined), + workflowHasResponseBlock: vi.fn().mockReturnValue(false), })) // Mock database diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 4c1f363c950..deaf91ff31c 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -7,7 +7,11 @@ import { persistExecutionError, persistExecutionLogs } from '@/lib/logs/executio import { buildTraceSpans } from '@/lib/logs/trace-spans' import { checkServerSideUsageLimits } from '@/lib/usage-monitor' import { decryptSecret } from '@/lib/utils' -import { updateWorkflowRunCounts } from '@/lib/workflows/utils' +import { + createHttpResponseFromBlock, + updateWorkflowRunCounts, + workflowHasResponseBlock, +} from '@/lib/workflows/utils' import { db } from '@/db' import { environment, userStats } from '@/db/schema' import { Executor } from '@/executor' @@ -304,6 +308,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const result = await executeWorkflow(validation.workflow, requestId) + + // Check if the workflow execution contains a response block output + const hasResponseBlock = workflowHasResponseBlock(result) + if (hasResponseBlock) { + return createHttpResponseFromBlock(result) + } + return createSuccessResponse(result) } catch (error: any) { logger.error(`[${requestId}] Error executing workflow: ${id}`, error) @@ -357,6 +368,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Execute workflow with the structured input const result = await executeWorkflow(validation.workflow, requestId, input) + + // Check if the workflow execution contains a response block output + const hasResponseBlock = workflowHasResponseBlock(result) + if (hasResponseBlock) { + return createHttpResponseFromBlock(result) + } + return createSuccessResponse(result) } catch (error: any) { logger.error(`[${requestId}] Error executing workflow: ${id}`, error) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx index 13a76cf48e5..d47adca2d8e 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/code.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Wand2 } from 'lucide-react' import { highlight, languages } from 'prismjs' import 'prismjs/components/prism-javascript' @@ -24,7 +24,7 @@ interface CodeProps { isConnecting: boolean placeholder?: string language?: 'javascript' | 'json' - generationType?: 'javascript-function-body' | 'json-schema' + generationType?: 'javascript-function-body' | 'json-schema' | 'json-object' value?: string isPreview?: boolean previewValue?: string | null @@ -62,10 +62,16 @@ export function Code({ disabled = false, }: CodeProps) { // Determine the AI prompt placeholder based on language - const aiPromptPlaceholder = - language === 'json' - ? 'Describe the JSON schema to generate...' - : 'Describe the JavaScript code to generate...' + const aiPromptPlaceholder = useMemo(() => { + switch (generationType) { + case 'json-schema': + return 'Describe the JSON schema to generate...' + case 'json-object': + return 'Describe the JSON object to generate...' + default: + return 'Describe the JavaScript code to generate...' + } + }, [generationType]) // State management const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx new file mode 100644 index 00000000000..fb7b5e8bf90 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/property-renderer.tsx @@ -0,0 +1,236 @@ +import { ChevronDown, ChevronRight, Plus, Trash } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import type { JSONProperty } from '../response-format' +import { ValueInput } from './value-input' + +const TYPE_ICONS = { + string: 'Aa', + number: '123', + boolean: 'T/F', + object: '{}', + array: '[]', +} + +const TYPE_COLORS = { + string: 'text-green-600 dark:text-green-400', + number: 'text-blue-600 dark:text-blue-400', + boolean: 'text-purple-600 dark:text-purple-400', + object: 'text-orange-600 dark:text-orange-400', + array: 'text-pink-600 dark:text-pink-400', +} + +interface PropertyRendererProps { + property: JSONProperty + blockId: string + isPreview: boolean + onUpdateProperty: (id: string, updates: Partial) => void + onAddProperty: (parentId?: string) => void + onRemoveProperty: (id: string) => void + onAddArrayItem: (arrayPropId: string) => void + onRemoveArrayItem: (arrayPropId: string, index: number) => void + onUpdateArrayItem: (arrayPropId: string, index: number, newValue: any) => void + depth?: number +} + +export function PropertyRenderer({ + property, + blockId, + isPreview, + onUpdateProperty, + onAddProperty, + onRemoveProperty, + onAddArrayItem, + onRemoveArrayItem, + onUpdateArrayItem, + depth = 0, +}: PropertyRendererProps) { + const isContainer = property.type === 'object' + const indent = depth * 12 + + // Check if this object is using a variable reference + const isObjectVariable = + property.type === 'object' && + typeof property.value === 'string' && + property.value.trim().startsWith('<') && + property.value.trim().includes('>') + + return ( +
+
+
+ {isContainer && !isObjectVariable && ( + + )} + + + {TYPE_ICONS[property.type]} + + + onUpdateProperty(property.id, { key: e.target.value })} + placeholder='key' + disabled={isPreview} + className='h-6 min-w-0 flex-1 text-xs' + /> + + + + + + + {Object.entries(TYPE_ICONS).map(([type, icon]) => ( + onUpdateProperty(property.id, { type: type as any })} + className='text-xs' + > + {icon} + {type} + + ))} + + + +
+ {isContainer && !isObjectVariable && ( + + )} + + +
+
+ + {/* Show value input for non-container types OR container types using variables */} + {(!isContainer || isObjectVariable) && ( +
+ +
+ )} + + {/* Show object variable input for object types */} + {isContainer && !isObjectVariable && ( +
+ ) => + onUpdateProperty(property.id, updates) + } + onAddArrayItem={onAddArrayItem} + onRemoveArrayItem={onRemoveArrayItem} + onUpdateArrayItem={onUpdateArrayItem} + placeholder='Use or define properties below' + onObjectVariableChange={(newValue: string) => { + if (newValue.startsWith('<')) { + onUpdateProperty(property.id, { value: newValue }) + } else if (newValue === '') { + onUpdateProperty(property.id, { value: [] }) + } + }} + /> +
+ )} +
+ + {isContainer && !property.collapsed && !isObjectVariable && ( +
+ {Array.isArray(property.value) && property.value.length > 0 ? ( + property.value.map((childProp: JSONProperty) => ( + + )) + ) : ( +
+

No properties

+ +
+ )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx new file mode 100644 index 00000000000..6109affb3aa --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/components/value-input.tsx @@ -0,0 +1,300 @@ +import { useRef, useState } from 'react' +import { Plus, Trash } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown' +import { Input } from '@/components/ui/input' +import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' +import { createLogger } from '@/lib/logs/console-logger' +import type { JSONProperty } from '../response-format' + +const logger = createLogger('ValueInput') + +interface ValueInputProps { + property: JSONProperty + blockId: string + isPreview: boolean + onUpdateProperty: (id: string, updates: Partial) => void + onAddArrayItem: (arrayPropId: string) => void + onRemoveArrayItem: (arrayPropId: string, index: number) => void + onUpdateArrayItem: (arrayPropId: string, index: number, newValue: any) => void + placeholder?: string + onObjectVariableChange?: (newValue: string) => void +} + +export function ValueInput({ + property, + blockId, + isPreview, + onUpdateProperty, + onAddArrayItem, + onRemoveArrayItem, + onUpdateArrayItem, + placeholder, + onObjectVariableChange, +}: ValueInputProps) { + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [cursorPosition, setCursorPosition] = useState(0) + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + + const inputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({}) + + const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => { + for (const prop of props) { + if (prop.id === id) return prop + if (prop.type === 'object' && Array.isArray(prop.value)) { + const found = findPropertyById(prop.value, id) + if (found) return found + } + } + return null + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + } + + const handleDrop = (e: React.DragEvent, propId: string) => { + if (isPreview) return + e.preventDefault() + + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return + + const input = inputRefs.current[propId] + const dropPosition = input?.selectionStart ?? 0 + + const currentValue = property.value?.toString() ?? '' + const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}` + + input?.focus() + + Promise.resolve().then(() => { + onUpdateProperty(property.id, { value: newValue }) + setCursorPosition(dropPosition + 1) + setShowTags(true) + + if (data.connectionData?.sourceBlockId) { + setActiveSourceBlockId(data.connectionData.sourceBlockId) + } + + setTimeout(() => { + if (input) { + input.selectionStart = dropPosition + 1 + input.selectionEnd = dropPosition + 1 + } + }, 0) + }) + } catch (error) { + logger.error('Failed to parse drop data:', { error }) + } + } + + const getPlaceholder = () => { + if (placeholder) return placeholder + + switch (property.type) { + case 'number': + return '42 or ' + case 'boolean': + return 'true/false or ' + case 'array': + return '["item1", "item2"] or ' + case 'object': + return '{...} or ' + default: + return 'Enter text or ' + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value + const cursorPos = e.target.selectionStart || 0 + + if (onObjectVariableChange) { + onObjectVariableChange(newValue.trim()) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + + if (!isPreview) { + const tagTrigger = checkTagTrigger(newValue, cursorPos) + const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos) + + setShowTags(tagTrigger.show) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.searchTerm || '') + setCursorPosition(cursorPos) + } + } + + const handleTagSelect = (newValue: string) => { + if (onObjectVariableChange) { + onObjectVariableChange(newValue) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + setShowTags(false) + } + + const handleEnvVarSelect = (newValue: string) => { + if (onObjectVariableChange) { + onObjectVariableChange(newValue) + } else { + onUpdateProperty(property.id, { value: newValue }) + } + setShowEnvVars(false) + } + + const isArrayVariable = + property.type === 'array' && + typeof property.value === 'string' && + property.value.trim().startsWith('<') && + property.value.trim().includes('>') + + // Handle array type with individual items + if (property.type === 'array' && !isArrayVariable && Array.isArray(property.value)) { + return ( +
+
+ { + inputRefs.current[`${property.id}-array-variable`] = el + }} + value={typeof property.value === 'string' ? property.value : ''} + onChange={(e) => { + const newValue = e.target.value.trim() + if (newValue.startsWith('<') || newValue.startsWith('[')) { + onUpdateProperty(property.id, { value: newValue }) + } else if (newValue === '') { + onUpdateProperty(property.id, { value: [] }) + } + + const cursorPos = e.target.selectionStart || 0 + if (!isPreview) { + const tagTrigger = checkTagTrigger(newValue, cursorPos) + const envVarTrigger = checkEnvVarTrigger(newValue, cursorPos) + + setShowTags(tagTrigger.show) + setShowEnvVars(envVarTrigger.show) + setSearchTerm(envVarTrigger.searchTerm || '') + setCursorPosition(cursorPos) + } + }} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, `${property.id}-array-variable`)} + placeholder='Use or define items below' + disabled={isPreview} + className='h-7 text-xs' + /> + {!isPreview && showTags && ( + setShowTags(false)} + /> + )} + {!isPreview && showEnvVars && ( + setShowEnvVars(false)} + /> + )} +
+ + {property.value.length > 0 && ( + <> +
Array Items:
+ {property.value.map((item: any, index: number) => ( +
+
+ { + inputRefs.current[`${property.id}-array-${index}`] = el + }} + value={item || ''} + onChange={(e) => onUpdateArrayItem(property.id, index, e.target.value)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, `${property.id}-array-${index}`)} + placeholder={`Item ${index + 1}`} + disabled={isPreview} + className='h-7 text-xs' + /> +
+ +
+ ))} + + )} + + +
+ ) + } + + // Handle regular input for all other types + return ( +
+ { + inputRefs.current[property.id] = el + }} + value={property.value || ''} + onChange={handleInputChange} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, property.id)} + placeholder={getPlaceholder()} + disabled={isPreview} + className='h-7 text-xs' + /> + {!isPreview && showTags && ( + setShowTags(false)} + /> + )} + {!isPreview && showEnvVars && ( + setShowEnvVars(false)} + /> + )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx new file mode 100644 index 00000000000..edef012ccc8 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/response/response-format.tsx @@ -0,0 +1,326 @@ +import { useState } from 'react' +import { Code, Eye, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { useSubBlockValue } from '../../hooks/use-sub-block-value' +import { PropertyRenderer } from './components/property-renderer' + +export interface JSONProperty { + id: string + key: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' + value: any + collapsed?: boolean +} + +interface ResponseFormatProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: JSONProperty[] | null +} + +const TYPE_ICONS = { + string: 'Aa', + number: '123', + boolean: 'T/F', + object: '{}', + array: '[]', +} + +const TYPE_COLORS = { + string: 'text-green-600 dark:text-green-400', + number: 'text-blue-600 dark:text-blue-400', + boolean: 'text-purple-600 dark:text-purple-400', + object: 'text-orange-600 dark:text-orange-400', + array: 'text-pink-600 dark:text-pink-400', +} + +const DEFAULT_PROPERTY: JSONProperty = { + id: crypto.randomUUID(), + key: 'message', + type: 'string', + value: '', + collapsed: false, +} + +export function ResponseFormat({ + blockId, + subBlockId, + isPreview = false, + previewValue, +}: ResponseFormatProps) { + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [showPreview, setShowPreview] = useState(false) + + const value = isPreview ? previewValue : storeValue + const properties: JSONProperty[] = value || [DEFAULT_PROPERTY] + + const isVariableReference = (value: any): boolean => { + return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>') + } + + const findPropertyById = (props: JSONProperty[], id: string): JSONProperty | null => { + for (const prop of props) { + if (prop.id === id) return prop + if (prop.type === 'object' && Array.isArray(prop.value)) { + const found = findPropertyById(prop.value, id) + if (found) return found + } + } + return null + } + + const generateJSON = (props: JSONProperty[]): any => { + const result: any = {} + + for (const prop of props) { + if (!prop.key.trim()) return + + let value = prop.value + + if (prop.type === 'object') { + if (Array.isArray(prop.value)) { + value = generateJSON(prop.value) + } else if (typeof prop.value === 'string' && isVariableReference(prop.value)) { + value = prop.value + } else { + value = {} // Default empty object for non-array, non-variable values + } + } else if (prop.type === 'array' && Array.isArray(prop.value)) { + value = prop.value.map((item: any) => { + if (typeof item === 'object' && item.type) { + if (item.type === 'object' && Array.isArray(item.value)) { + return generateJSON(item.value) + } + if (item.type === 'array' && Array.isArray(item.value)) { + return item.value.map((subItem: any) => + typeof subItem === 'object' && subItem.type ? subItem.value : subItem + ) + } + return item.value + } + return item + }) + } else if (prop.type === 'number' && !isVariableReference(value)) { + value = Number.isNaN(Number(value)) ? value : Number(value) + } else if (prop.type === 'boolean' && !isVariableReference(value)) { + const strValue = String(value).toLowerCase().trim() + value = strValue === 'true' || strValue === '1' || strValue === 'yes' || strValue === 'on' + } + + result[prop.key] = value + } + + return result + } + + const updateProperties = (newProperties: JSONProperty[]) => { + if (isPreview) return + setStoreValue(newProperties) + } + + const updateProperty = (id: string, updates: Partial) => { + const updateRecursive = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === id) { + const updated = { ...prop, ...updates } + + if (updates.type && updates.type !== prop.type) { + if (updates.type === 'object') { + updated.value = [] + } else if (updates.type === 'array') { + updated.value = [] + } else if (updates.type === 'boolean') { + updated.value = 'false' + } else if (updates.type === 'number') { + updated.value = '0' + } else { + updated.value = '' + } + } + + return updated + } + + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: updateRecursive(prop.value) } + } + + return prop + }) + } + + updateProperties(updateRecursive(properties)) + } + + const addProperty = (parentId?: string) => { + const newProp: JSONProperty = { + id: crypto.randomUUID(), + key: '', + type: 'string', + value: '', + collapsed: false, + } + + if (parentId) { + const addToParent = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === parentId && prop.type === 'object') { + return { ...prop, value: [...(prop.value || []), newProp] } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: addToParent(prop.value) } + } + return prop + }) + } + updateProperties(addToParent(properties)) + } else { + updateProperties([...properties, newProp]) + } + } + + const removeProperty = (id: string) => { + const removeRecursive = (props: JSONProperty[]): JSONProperty[] => { + return props + .filter((prop) => prop.id !== id) + .map((prop) => { + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: removeRecursive(prop.value) } + } + return prop + }) + } + + const newProperties = removeRecursive(properties) + updateProperties( + newProperties.length > 0 + ? newProperties + : [ + { + id: crypto.randomUUID(), + key: '', + type: 'string', + value: '', + collapsed: false, + }, + ] + ) + } + + const addArrayItem = (arrayPropId: string) => { + const addItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + return { ...prop, value: [...(prop.value || []), ''] } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: addItem(prop.value) } + } + return prop + }) + } + updateProperties(addItem(properties)) + } + + const removeArrayItem = (arrayPropId: string, index: number) => { + const removeItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + const newValue = [...(prop.value || [])] + newValue.splice(index, 1) + return { ...prop, value: newValue } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: removeItem(prop.value) } + } + return prop + }) + } + updateProperties(removeItem(properties)) + } + + const updateArrayItem = (arrayPropId: string, index: number, newValue: any) => { + const updateItem = (props: JSONProperty[]): JSONProperty[] => { + return props.map((prop) => { + if (prop.id === arrayPropId && prop.type === 'array') { + const updatedValue = [...(prop.value || [])] + updatedValue[index] = newValue + return { ...prop, value: updatedValue } + } + if (prop.type === 'object' && Array.isArray(prop.value)) { + return { ...prop, value: updateItem(prop.value) } + } + return prop + }) + } + updateProperties(updateItem(properties)) + } + + const hasConfiguredProperties = properties.some((prop) => prop.key.trim()) + + return ( +
+
+ +
+ + +
+
+ + {showPreview && ( +
+
+            {JSON.stringify(generateJSON(properties), null, 2)}
+          
+
+ )} + +
+ {properties.map((prop) => ( + + ))} +
+ + {!hasConfiguredProperties && ( +
+

Build your JSON response format

+

+ Use <variable.name> in values or drag variables from above +

+
+ )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx index daf25ab62a3..5525f2f2c43 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx @@ -46,7 +46,7 @@ export function InputFormat({ // Use preview value when in preview mode, otherwise use store value const value = isPreview ? previewValue : storeValue - const fields: InputField[] = value || [DEFAULT_FIELD] + const fields: InputField[] = value || [] // Field operations const addField = () => { @@ -60,7 +60,7 @@ export function InputFormat({ } const removeField = (id: string) => { - if (isPreview || disabled || fields.length === 1) return + if (isPreview || disabled) return setStoreValue(fields.filter((field: InputField) => field.id !== id)) } @@ -117,7 +117,7 @@ export function InputFormat({ variant='ghost' size='icon' onClick={() => removeField(field.id)} - disabled={isPreview || disabled || fields.length === 1} + disabled={isPreview || disabled} className='h-6 w-6 rounded-full text-destructive hover:text-destructive' > @@ -134,96 +134,112 @@ export function InputFormat({ // Main render return (
- {fields.map((field, index) => { - const isUnconfigured = !field.name || field.name.trim() === '' - - return ( -
+

No input fields defined

+ - - - updateField(field.id, 'type', 'string')} - className='cursor-pointer' - > - Aa - String - - updateField(field.id, 'type', 'number')} - className='cursor-pointer' - > - 123 - Number - - updateField(field.id, 'type', 'boolean')} - className='cursor-pointer' - > - 0/1 - Boolean - - updateField(field.id, 'type', 'object')} - className='cursor-pointer' - > - {'{}'} - Object - - updateField(field.id, 'type', 'array')} - className='cursor-pointer' - > - [] - Array - - - + + Add Field + +
+ ) : ( + fields.map((field, index) => { + const isUnconfigured = !field.name || field.name.trim() === '' + + return ( +
+ {renderFieldHeader(field, index)} + + {!field.collapsed && ( +
+
+ + updateField(field.id, 'name', e.target.value)} + placeholder='firstName' + disabled={isPreview || disabled} + className='h-9 placeholder:text-muted-foreground/50' + /> +
+ +
+ + + + + + + updateField(field.id, 'type', 'string')} + className='cursor-pointer' + > + Aa + String + + updateField(field.id, 'type', 'number')} + className='cursor-pointer' + > + 123 + Number + + updateField(field.id, 'type', 'boolean')} + className='cursor-pointer' + > + 0/1 + Boolean + + updateField(field.id, 'type', 'object')} + className='cursor-pointer' + > + {'{}'} + Object + + updateField(field.id, 'type', 'array')} + className='cursor-pointer' + > + [] + Array + + + +
-
- )} -
- ) - })} + )} + + ) + }) + )} - {!hasConfiguredFields && ( + {fields.length > 0 && !hasConfiguredFields && (
Define fields above to enable structured API input
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index 2a6f769a1c7..4b48f2798bf 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -19,6 +19,7 @@ import { FolderSelectorInput } from './components/folder-selector/components/fol import { KnowledgeBaseSelector } from './components/knowledge-base-selector/knowledge-base-selector' import { LongInput } from './components/long-input' import { ProjectSelectorInput } from './components/project-selector/project-selector-input' +import { ResponseFormat } from './components/response/response-format' import { ScheduleConfig } from './components/schedule/schedule-config' import { ShortInput } from './components/short-input' import { SliderInput } from './components/slider-input' @@ -352,7 +353,7 @@ export function SubBlock({ previewValue={previewValue} /> ) - case 'input-format': + case 'input-format': { return ( ) + } + case 'response-format': + return ( + + ) case 'channel-selector': return ( ) { {/* Output Handle */} - {type !== 'condition' && ( + {type !== 'condition' && type !== 'response' && ( <> = { + type: 'response', + name: 'Response', + description: 'Send a structured response back to API calls only', + longDescription: + "Transform your workflow's variables into a structured HTTP response for API calls. Define response data, status code, and headers. This is the final block in a workflow and cannot have further connections.", + docsLink: 'https://docs.simstudio.ai/blocks/response', + category: 'blocks', + bgColor: '#2F55FF', + icon: ResponseIcon, + subBlocks: [ + { + id: 'dataMode', + title: 'Response Data Mode', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Builder', id: 'structured' }, + { label: 'Editor', id: 'json' }, + ], + value: () => 'structured', + description: 'Choose how to define your response data structure', + }, + { + id: 'builderData', + title: 'Response Structure', + type: 'response-format', + layout: 'full', + condition: { field: 'dataMode', value: 'structured' }, + description: + 'Define the structure of your response data. Use in field names to reference workflow variables.', + }, + { + id: 'data', + title: 'Response Data', + type: 'code', + layout: 'full', + placeholder: '{\n "message": "Hello world",\n "userId": ""\n}', + language: 'json', + generationType: 'json-object', + condition: { field: 'dataMode', value: 'json' }, + description: + 'Data that will be sent as the response body on API calls. Use to reference workflow variables.', + }, + { + id: 'status', + title: 'Status Code', + type: 'short-input', + layout: 'half', + placeholder: '200', + description: 'HTTP status code (default: 200)', + }, + { + id: 'headers', + title: 'Response Headers', + type: 'table', + layout: 'full', + columns: ['Key', 'Value'], + description: 'Additional HTTP headers to include in the response', + }, + ], + tools: { access: [] }, + inputs: { + dataMode: { + type: 'string', + required: false, + description: 'Mode for defining response data structure', + }, + builderData: { + type: 'json', + required: false, + description: 'The JSON data to send in the response body', + }, + data: { + type: 'json', + required: false, + description: 'The JSON data to send in the response body', + }, + status: { + type: 'number', + required: false, + description: 'HTTP status code (default: 200)', + }, + headers: { + type: 'json', + required: false, + description: 'Additional response headers', + }, + }, + outputs: { + response: { + type: { + data: 'json', + status: 'number', + headers: 'json', + }, + }, + }, +} diff --git a/apps/sim/blocks/blocks/starter.ts b/apps/sim/blocks/blocks/starter.ts index 53815868305..8a5ace14869 100644 --- a/apps/sim/blocks/blocks/starter.ts +++ b/apps/sim/blocks/blocks/starter.ts @@ -32,13 +32,13 @@ export const StarterBlock: BlockConfig = { value: () => 'manual', }, // Structured Input format - visible if manual run is selected - // { - // id: 'inputFormat', - // title: 'Input Format (for API calls)', - // type: 'input-format', - // layout: 'full', - // condition: { field: 'startWorkflow', value: 'manual' }, - // }, + { + id: 'inputFormat', + title: 'Input Format (for API calls)', + type: 'input-format', + layout: 'full', + condition: { field: 'startWorkflow', value: 'manual' }, + }, // Webhook configuration { id: 'webhookProvider', diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 9fb874bc0a4..9b45d9c3d0f 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -45,6 +45,7 @@ import { OutlookBlock } from './blocks/outlook' import { PerplexityBlock } from './blocks/perplexity' import { PineconeBlock } from './blocks/pinecone' import { RedditBlock } from './blocks/reddit' +import { ResponseBlock } from './blocks/response' import { RouterBlock } from './blocks/router' import { S3Block } from './blocks/s3' import { SerperBlock } from './blocks/serper' @@ -128,6 +129,7 @@ export const registry: Record = { x: XBlock, youtube: YouTubeBlock, huggingface: HuggingFaceBlock, + response: ResponseBlock, } // Helper functions to access the registry diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index b178115de6e..56baf615925 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -34,6 +34,7 @@ export type SubBlockType = | 'knowledge-base-selector' // Knowledge base selector | 'document-selector' // Document selector for knowledge bases | 'input-format' // Input structure format + | 'response-format' // Response structure format | 'file-upload' // File uploader // Component width setting @@ -114,7 +115,7 @@ export interface SubBlockConfig { } // Props specific to 'code' sub-block type language?: 'javascript' | 'json' - generationType?: 'javascript-function-body' | 'json-schema' + generationType?: 'javascript-function-body' | 'json-schema' | 'json-object' // OAuth specific properties provider?: string serviceId?: string diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index db6522097fe..c71c808da4b 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2937,3 +2937,10 @@ export function HuggingFaceIcon(props: SVGProps) { ) } + +export const ResponseIcon = (props: SVGProps) => ( + + + + +) diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts index 43771b070e2..a37187ae56b 100644 --- a/apps/sim/executor/__test-utils__/executor-mocks.ts +++ b/apps/sim/executor/__test-utils__/executor-mocks.ts @@ -39,6 +39,7 @@ export const setupHandlerMocks = () => { ParallelBlockHandler: createMockHandler('parallel'), WorkflowBlockHandler: createMockHandler('workflow'), GenericBlockHandler: createMockHandler('generic'), + ResponseBlockHandler: createMockHandler('response'), })) } @@ -402,6 +403,48 @@ export const createWorkflowWithParallel = (distribution?: any): SerializedWorkfl }, }) +export const createWorkflowWithResponse = (): SerializedWorkflow => ({ + version: '1.0', + blocks: [ + { + id: 'starter', + position: { x: 0, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: { + input: 'json', + }, + outputs: { + response: { + input: 'json', + }, + }, + enabled: true, + metadata: { id: 'starter', name: 'Starter Block' }, + }, + { + id: 'response', + position: { x: 100, y: 0 }, + config: { tool: 'test-tool', params: {} }, + inputs: { + data: 'json', + status: 'number', + headers: 'json', + }, + outputs: { + response: { + data: 'json', + status: 'number', + headers: 'json', + }, + }, + enabled: true, + metadata: { id: 'response', name: 'Response Block' }, + }, + ], + connections: [{ source: 'starter', target: 'response' }], + loops: {}, +}) + /** * Create a mock execution context with customizable options */ diff --git a/apps/sim/executor/handlers/index.ts b/apps/sim/executor/handlers/index.ts index 51ad100c5ae..754832ce82f 100644 --- a/apps/sim/executor/handlers/index.ts +++ b/apps/sim/executor/handlers/index.ts @@ -6,6 +6,7 @@ import { FunctionBlockHandler } from './function/function-handler' import { GenericBlockHandler } from './generic/generic-handler' import { LoopBlockHandler } from './loop/loop-handler' import { ParallelBlockHandler } from './parallel/parallel-handler' +import { ResponseBlockHandler } from './response/response-handler' import { RouterBlockHandler } from './router/router-handler' import { WorkflowBlockHandler } from './workflow/workflow-handler' @@ -18,6 +19,7 @@ export { GenericBlockHandler, LoopBlockHandler, ParallelBlockHandler, + ResponseBlockHandler, RouterBlockHandler, WorkflowBlockHandler, } diff --git a/apps/sim/executor/handlers/response/response-handler.ts b/apps/sim/executor/handlers/response/response-handler.ts new file mode 100644 index 00000000000..9450afc0e79 --- /dev/null +++ b/apps/sim/executor/handlers/response/response-handler.ts @@ -0,0 +1,245 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { BlockOutput } from '@/blocks/types' +import type { SerializedBlock } from '@/serializer/types' +import type { BlockHandler } from '../../types' + +const logger = createLogger('ResponseBlockHandler') + +interface JSONProperty { + id: string + key: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' + value: any + collapsed?: boolean +} + +export class ResponseBlockHandler implements BlockHandler { + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === 'response' + } + + async execute(block: SerializedBlock, inputs: Record): Promise { + logger.info(`Executing response block: ${block.id}`) + + try { + const responseData = this.parseResponseData(inputs) + const statusCode = this.parseStatus(inputs.status) + const responseHeaders = this.parseHeaders(inputs.headers) + + logger.info('Response prepared', { + status: statusCode, + dataKeys: Object.keys(responseData), + headerKeys: Object.keys(responseHeaders), + }) + + return { + response: { + data: responseData, + status: statusCode, + headers: responseHeaders, + }, + } + } catch (error: any) { + logger.error('Response block execution failed:', error) + return { + response: { + data: { + error: 'Response block execution failed', + message: error.message || 'Unknown error', + }, + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + } + } + } + + private parseResponseData(inputs: Record): any { + const dataMode = inputs.dataMode || 'structured' + + if (dataMode === 'json' && inputs.data) { + // Handle JSON mode - data comes from code editor + if (typeof inputs.data === 'string') { + try { + return JSON.parse(inputs.data) + } catch (error) { + logger.warn('Failed to parse JSON data, returning as string:', error) + return inputs.data + } + } else if (typeof inputs.data === 'object' && inputs.data !== null) { + // Data is already an object, return as-is + return inputs.data + } + return inputs.data + } + + if (dataMode === 'structured' && inputs.builderData) { + // Handle structured mode - convert builderData to JSON + const convertedData = this.convertBuilderDataToJson(inputs.builderData) + return this.parseObjectStrings(convertedData) + } + + // Fallback to inputs.data for backward compatibility + return inputs.data || {} + } + + private convertBuilderDataToJson(builderData: JSONProperty[]): any { + if (!Array.isArray(builderData)) { + return {} + } + + const result: any = {} + + for (const prop of builderData) { + if (!prop.key.trim()) { + return + } + + const value = this.convertPropertyValue(prop) + result[prop.key] = value + } + + return result + } + + private convertPropertyValue(prop: JSONProperty): any { + switch (prop.type) { + case 'object': + return this.convertObjectValue(prop.value) + case 'array': + return this.convertArrayValue(prop.value) + case 'number': + return this.convertNumberValue(prop.value) + case 'boolean': + return this.convertBooleanValue(prop.value) + default: + return prop.value + } + } + + private convertObjectValue(value: any): any { + if (Array.isArray(value)) { + return this.convertBuilderDataToJson(value) + } + + if (typeof value === 'string' && !this.isVariableReference(value)) { + return this.tryParseJson(value, value) + } + + // Keep variable references or other values as-is (they'll be resolved later) + return value + } + + private convertArrayValue(value: any): any { + if (Array.isArray(value)) { + return value.map((item: any) => this.convertArrayItem(item)) + } + + if (typeof value === 'string' && !this.isVariableReference(value)) { + const parsed = this.tryParseJson(value, value) + return Array.isArray(parsed) ? parsed : value + } + + // Keep variable references or other values as-is + return value + } + + private convertArrayItem(item: any): any { + if (typeof item !== 'object' || !item.type) { + return item + } + + if (item.type === 'object' && Array.isArray(item.value)) { + return this.convertBuilderDataToJson(item.value) + } + + if (item.type === 'array' && Array.isArray(item.value)) { + return item.value.map((subItem: any) => + typeof subItem === 'object' && subItem.type ? subItem.value : subItem + ) + } + + return item.value + } + + private convertNumberValue(value: any): any { + if (this.isVariableReference(value)) { + return value + } + + const numValue = Number(value) + return Number.isNaN(numValue) ? value : numValue + } + + private convertBooleanValue(value: any): any { + if (this.isVariableReference(value)) { + return value + } + + return value === 'true' || value === true + } + + private tryParseJson(jsonString: string, fallback: any): any { + try { + return JSON.parse(jsonString) + } catch { + return fallback + } + } + + private isVariableReference(value: any): boolean { + return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>') + } + + private parseObjectStrings(data: any): any { + if (typeof data === 'string') { + // Try to parse strings that might be JSON objects + try { + const parsed = JSON.parse(data) + if (typeof parsed === 'object' && parsed !== null) { + return this.parseObjectStrings(parsed) // Recursively parse nested objects + } + return parsed + } catch { + return data // Return as string if not valid JSON + } + } else if (Array.isArray(data)) { + return data.map((item) => this.parseObjectStrings(item)) + } else if (typeof data === 'object' && data !== null) { + const result: any = {} + for (const [key, value] of Object.entries(data)) { + result[key] = this.parseObjectStrings(value) + } + return result + } + return data + } + + private parseStatus(status?: string): number { + if (!status) return 200 + const parsed = Number(status) + if (Number.isNaN(parsed) || parsed < 100 || parsed > 599) { + return 200 + } + return parsed + } + + private parseHeaders( + headers: { + id: string + cells: { Key: string; Value: string } + }[] + ): Record { + const defaultHeaders = { 'Content-Type': 'application/json' } + if (!headers) return defaultHeaders + + const headerObj = headers.reduce((acc: Record, header) => { + if (header?.cells?.Key && header?.cells?.Value) { + acc[header.cells.Key] = header.cells.Value + } + return acc + }, {}) + + return { ...defaultHeaders, ...headerObj } + } +} diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index 22df97ee186..211015c51dc 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -13,6 +13,7 @@ import { GenericBlockHandler, LoopBlockHandler, ParallelBlockHandler, + ResponseBlockHandler, RouterBlockHandler, WorkflowBlockHandler, } from './handlers/index' @@ -143,6 +144,7 @@ export class Executor { new ApiBlockHandler(), new LoopBlockHandler(this.resolver), new ParallelBlockHandler(this.resolver), + new ResponseBlockHandler(), new WorkflowBlockHandler(), new GenericBlockHandler(), ] @@ -560,8 +562,7 @@ export class Executor { if (starterBlock) { // Initialize the starter block with the workflow input try { - const _blockParams = starterBlock.config.params - /* Commenting out input format handling + const blockParams = starterBlock.config.params const inputFormat = blockParams?.inputFormat // If input format is defined, structure the input according to the schema @@ -575,12 +576,15 @@ export class Executor { // Get the field value from workflow input if available // First try to access via input.field, then directly from field // This handles both input formats: { input: { field: value } } and { field: value } - const inputValue = this.workflowInput?.input?.[field.name] !== undefined - ? this.workflowInput.input[field.name] // Try to get from input.field - : this.workflowInput?.[field.name] // Fallback to direct field access - - logger.info(`[Executor] Processing input field ${field.name} (${field.type}):`, - inputValue !== undefined ? JSON.stringify(inputValue) : 'undefined') + const inputValue = + this.workflowInput?.input?.[field.name] !== undefined + ? this.workflowInput.input[field.name] // Try to get from input.field + : this.workflowInput?.[field.name] // Fallback to direct field access + + logger.info( + `[Executor] Processing input field ${field.name} (${field.type}):`, + inputValue !== undefined ? JSON.stringify(inputValue) : 'undefined' + ) // Convert the value to the appropriate type let typedValue = inputValue @@ -608,15 +612,16 @@ export class Executor { // Check if we managed to process any fields - if not, use the raw input const hasProcessedFields = Object.keys(structuredInput).length > 0 - + // If no fields matched the input format, extract the raw input to use instead - const rawInputData = this.workflowInput?.input !== undefined - ? this.workflowInput.input // Use the nested input data - : this.workflowInput // Fallback to direct input - + const rawInputData = + this.workflowInput?.input !== undefined + ? this.workflowInput.input // Use the nested input data + : this.workflowInput // Fallback to direct input + // Use the structured input if we processed fields, otherwise use raw input const finalInput = hasProcessedFields ? structuredInput : rawInputData - + // Initialize the starter block with structured input // Ensure both input and direct fields are available const starterOutput = { @@ -625,7 +630,7 @@ export class Executor { ...finalInput, // Add input fields directly at response level too }, } - + logger.info(`[Executor] Starter output:`, JSON.stringify(starterOutput, null, 2)) context.blockStates.set(starterBlock.id, { @@ -634,40 +639,40 @@ export class Executor { executionTime: 0, }) } else { - */ - // Handle structured input (like API calls or chat messages) - if (this.workflowInput && typeof this.workflowInput === 'object') { - // Preserve complete workflowInput structure to maintain JSON format - // when referenced through - const starterOutput = { - response: { - input: this.workflowInput, - // Add top-level fields for backward compatibility - message: this.workflowInput.input, - conversationId: this.workflowInput.conversationId, - }, - } + // Handle structured input (like API calls or chat messages) + if (this.workflowInput && typeof this.workflowInput === 'object') { + // Preserve complete workflowInput structure to maintain JSON format + // when referenced through + + const starterOutput = { + response: { + input: this.workflowInput, + // Add top-level fields for backward compatibility + message: this.workflowInput.input, + conversationId: this.workflowInput.conversationId, + }, + } - context.blockStates.set(starterBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) - } else { - // Fallback for primitive input values - const starterOutput = { - response: { - input: this.workflowInput, - }, - } + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + } else { + // Fallback for primitive input values + const starterOutput = { + response: { + input: this.workflowInput, + }, + } - context.blockStates.set(starterBlock.id, { - output: starterOutput, - executed: true, - executionTime: 0, - }) + context.blockStates.set(starterBlock.id, { + output: starterOutput, + executed: true, + executionTime: 0, + }) + } } - //} // End of inputFormat conditional } catch (e) { logger.warn('Error processing starter block input format:', e) diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 50f82abecfc..8d1c0338abd 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -882,7 +882,9 @@ export function verifyProviderWebhook( logger.warn( `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` ) - return new NextResponse('Forbidden - IP not allowed', { status: 403 }) + return new NextResponse('Forbidden - IP not allowed', { + status: 403, + }) } } break @@ -915,7 +917,9 @@ export async function fetchAndProcessAirtablePayloads( let apiCallCount = 0 // Use a Map to consolidate changes per record ID const consolidatedChangesMap = new Map() - const localProviderConfig = { ...((webhookData.providerConfig as Record) || {}) } // Local copy + const localProviderConfig = { + ...((webhookData.providerConfig as Record) || {}), + } // Local copy // DEBUG: Log start of function execution with critical info logger.debug(`[${requestId}] TRACE: fetchAndProcessAirtablePayloads started`, { @@ -959,7 +963,10 @@ export async function fetchAndProcessAirtablePayloads( await db .update(webhook) .set({ - providerConfig: { ...localProviderConfig, externalWebhookCursor: null }, + providerConfig: { + ...localProviderConfig, + externalWebhookCursor: null, + }, updatedAt: new Date(), }) .where(eq(webhook.id, webhookData.id)) @@ -1056,7 +1063,10 @@ export async function fetchAndProcessAirtablePayloads( const fetchStartTime = Date.now() const response = await fetch(fullUrl, { method: 'GET', - headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, }) // DEBUG: Log API response time @@ -1076,7 +1086,11 @@ export async function fetchAndProcessAirtablePayloads( `Airtable API error Status ${response.status}` logger.error( `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { webhookId: webhookData.id, status: response.status, error: errorMessage } + { + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + } ) await persistExecutionError( workflowData.id, @@ -1206,7 +1220,10 @@ export async function fetchAndProcessAirtablePayloads( currentCursor = nextCursor // Follow exactly the old implementation - use awaited update instead of parallel - const updatedConfig = { ...localProviderConfig, externalWebhookCursor: currentCursor } + const updatedConfig = { + ...localProviderConfig, + externalWebhookCursor: currentCursor, + } try { // Force a complete object update to ensure consistency in serverless env await db diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index b057de8464b..5f189f00f2c 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -1,7 +1,9 @@ import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { userStats, workflow as workflowTable } from '@/db/schema' +import type { ExecutionResult } from '@/executor/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { env } from '../env' @@ -317,3 +319,36 @@ export function hasWorkflowChanged( export function stripCustomToolPrefix(name: string) { return name.startsWith('custom_') ? name.replace('custom_', '') : name } + +export const workflowHasResponseBlock = (executionResult: ExecutionResult): boolean => { + if ( + !executionResult?.logs || + !Array.isArray(executionResult.logs) || + !executionResult.success || + !executionResult.output.response + ) { + return false + } + + const responseBlock = executionResult.logs.find( + (log) => log?.blockType === 'response' && log?.success + ) + + return responseBlock !== undefined +} + +// Create a HTTP response from response block +export const createHttpResponseFromBlock = (executionResult: ExecutionResult): NextResponse => { + const output = executionResult.output.response + const { data = {}, status = 200, headers = {} } = output + + const responseHeaders = new Headers({ + 'Content-Type': 'application/json', + ...headers, + }) + + return NextResponse.json(data, { + status: status, + headers: responseHeaders, + }) +} diff --git a/apps/sim/tools/response/types.ts b/apps/sim/tools/response/types.ts new file mode 100644 index 00000000000..47f41e04a70 --- /dev/null +++ b/apps/sim/tools/response/types.ts @@ -0,0 +1,10 @@ +import type { ToolResponse } from '@/tools/types' + +export interface ResponseBlockOutput extends ToolResponse { + success: boolean + output: { + data: Record + status: number + headers: Record + } +}