mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
improvement(hitl): add webhook notification and resume, add webhook block (#2673)
* Add api blcok as tool * Add webhook block * Hitl v1 * Cleanup * Fix * Update names for fields in hitl * Fix hitl tag dropdown * Update hitl dashboard * Lint
This commit is contained in:
committed by
GitHub
parent
155f544ce8
commit
8215a819e5
@@ -21,12 +21,13 @@ export async function POST(
|
||||
) {
|
||||
const { workflowId, executionId, contextId } = await params
|
||||
|
||||
// Allow resume from dashboard without requiring deployment
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
}
|
||||
|
||||
const workflow = access.workflow!
|
||||
const workflow = access.workflow
|
||||
|
||||
let payload: any = {}
|
||||
try {
|
||||
@@ -148,6 +149,7 @@ export async function GET(
|
||||
) {
|
||||
const { workflowId, executionId, contextId } = await params
|
||||
|
||||
// Allow access without API key for browser-based UI (same as parent execution endpoint)
|
||||
const access = await validateWorkflowAccess(request, workflowId, false)
|
||||
if (access.error) {
|
||||
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -755,6 +755,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
|
||||
}
|
||||
} else if (sourceBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = activeSourceBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
// For self-reference, only show url and resumeEndpoint (not response format fields)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
}
|
||||
} else {
|
||||
const operationValue =
|
||||
mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation')
|
||||
@@ -1074,7 +1092,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags
|
||||
}
|
||||
} else if (accessibleBlock.type === 'human_in_the_loop') {
|
||||
blockTags = [`${normalizedBlockName}.url`]
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
|
||||
const isSelfReference = accessibleBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
// For self-reference, only show url and resumeEndpoint (not response format fields)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
|
||||
}
|
||||
} else {
|
||||
const operationValue =
|
||||
mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation')
|
||||
|
||||
@@ -760,6 +760,7 @@ function CodeEditorSyncWrapper({
|
||||
* in the tool selection dropdown.
|
||||
*/
|
||||
const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'api',
|
||||
'file',
|
||||
'function',
|
||||
'knowledge',
|
||||
@@ -772,6 +773,7 @@ const BUILT_IN_TOOL_TYPES = new Set([
|
||||
'tts',
|
||||
'stt',
|
||||
'memory',
|
||||
'webhook_request',
|
||||
'workflow',
|
||||
])
|
||||
|
||||
@@ -926,6 +928,8 @@ export function ToolInput({
|
||||
const toolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
(block.category === 'tools' ||
|
||||
block.type === 'api' ||
|
||||
block.type === 'webhook_request' ||
|
||||
block.type === 'workflow' ||
|
||||
block.type === 'knowledge' ||
|
||||
block.type === 'function') &&
|
||||
|
||||
@@ -27,7 +27,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
// },
|
||||
{
|
||||
id: 'builderData',
|
||||
title: 'Paused Output',
|
||||
title: 'Display Data',
|
||||
type: 'response-format',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description:
|
||||
@@ -35,7 +35,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
},
|
||||
{
|
||||
id: 'notification',
|
||||
title: 'Notification',
|
||||
title: 'Notification (Send URL)',
|
||||
type: 'tool-input',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description: 'Configure notification tools to alert approvers (e.g., Slack, Email)',
|
||||
@@ -57,7 +57,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
// },
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Resume Input',
|
||||
title: 'Resume Form',
|
||||
type: 'input-format',
|
||||
// condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode
|
||||
description: 'Define the fields the approver can fill in when resuming',
|
||||
@@ -157,6 +157,9 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
},
|
||||
outputs: {
|
||||
url: { type: 'string', description: 'Resume UI URL' },
|
||||
// apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output
|
||||
resumeEndpoint: {
|
||||
type: 'string',
|
||||
description: 'Resume API endpoint URL for direct curl requests',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
86
apps/sim/blocks/blocks/webhook_request.ts
Normal file
86
apps/sim/blocks/blocks/webhook_request.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { WebhookIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { RequestResponse } from '@/tools/http/types'
|
||||
|
||||
export const WebhookRequestBlock: BlockConfig<RequestResponse> = {
|
||||
type: 'webhook_request',
|
||||
name: 'Webhook',
|
||||
description: 'Send a webhook request',
|
||||
longDescription:
|
||||
'Send an HTTP POST request to a webhook URL with automatic webhook headers. Optionally sign the payload with HMAC-SHA256 for secure webhook delivery.',
|
||||
docsLink: 'https://docs.sim.ai/blocks/webhook',
|
||||
category: 'blocks',
|
||||
bgColor: '#10B981',
|
||||
icon: WebhookIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'url',
|
||||
title: 'Webhook URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://example.com/webhook',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'body',
|
||||
title: 'Payload',
|
||||
type: 'code',
|
||||
placeholder: 'Enter JSON payload...',
|
||||
language: 'json',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `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 }.
|
||||
|
||||
Current payload: {context}
|
||||
|
||||
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 payload:
|
||||
- Use angle brackets for workflow variables, e.g., '<blockName.output>'.
|
||||
- Use double curly braces for environment variables, e.g., '{{ENV_VAR_NAME}}'.
|
||||
|
||||
Example:
|
||||
{
|
||||
"event": "workflow.completed",
|
||||
"data": {
|
||||
"result": "<agent.content>",
|
||||
"timestamp": "<function.result>"
|
||||
}
|
||||
}`,
|
||||
placeholder: 'Describe the webhook payload you need...',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'secret',
|
||||
title: 'Signing Secret',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional: Secret for HMAC signature',
|
||||
password: true,
|
||||
connectionDroppable: false,
|
||||
},
|
||||
{
|
||||
id: 'headers',
|
||||
title: 'Additional Headers',
|
||||
type: 'table',
|
||||
columns: ['Key', 'Value'],
|
||||
description: 'Optional custom headers to include with the webhook request',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['webhook_request'],
|
||||
},
|
||||
inputs: {
|
||||
url: { type: 'string', description: 'Webhook URL to send the request to' },
|
||||
body: { type: 'json', description: 'JSON payload to send' },
|
||||
secret: { type: 'string', description: 'Optional secret for HMAC-SHA256 signature' },
|
||||
headers: { type: 'json', description: 'Optional additional headers' },
|
||||
},
|
||||
outputs: {
|
||||
data: { type: 'json', description: 'Response data from the webhook endpoint' },
|
||||
status: { type: 'number', description: 'HTTP status code' },
|
||||
headers: { type: 'json', description: 'Response headers' },
|
||||
},
|
||||
}
|
||||
@@ -131,6 +131,7 @@ import { WaitBlock } from '@/blocks/blocks/wait'
|
||||
import { WealthboxBlock } from '@/blocks/blocks/wealthbox'
|
||||
import { WebflowBlock } from '@/blocks/blocks/webflow'
|
||||
import { WebhookBlock } from '@/blocks/blocks/webhook'
|
||||
import { WebhookRequestBlock } from '@/blocks/blocks/webhook_request'
|
||||
import { WhatsAppBlock } from '@/blocks/blocks/whatsapp'
|
||||
import { WikipediaBlock } from '@/blocks/blocks/wikipedia'
|
||||
import { WordPressBlock } from '@/blocks/blocks/wordpress'
|
||||
@@ -280,6 +281,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
wealthbox: WealthboxBlock,
|
||||
webflow: WebflowBlock,
|
||||
webhook: WebhookBlock,
|
||||
webhook_request: WebhookRequestBlock,
|
||||
whatsapp: WhatsAppBlock,
|
||||
wikipedia: WikipediaBlock,
|
||||
wordpress: WordPressBlock,
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface SubBlockConfig {
|
||||
hideFromPreview?: boolean // Hide this subblock from the workflow block preview
|
||||
requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible
|
||||
description?: string
|
||||
tooltip?: string // Tooltip text displayed via info icon next to the title
|
||||
value?: (params: Record<string, any>) => string
|
||||
grouped?: boolean
|
||||
scrollable?: boolean
|
||||
|
||||
@@ -526,7 +526,7 @@ export class BlockExecutor {
|
||||
const placeholderState: BlockState = {
|
||||
output: {
|
||||
url: resumeLinks.uiUrl,
|
||||
// apiUrl: resumeLinks.apiUrl, // Hidden from output
|
||||
resumeEndpoint: resumeLinks.apiUrl,
|
||||
},
|
||||
executed: false,
|
||||
executionTime: existingState?.executionTime ?? 0,
|
||||
|
||||
@@ -227,7 +227,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
|
||||
if (resumeLinks) {
|
||||
output.url = resumeLinks.uiUrl
|
||||
// output.apiUrl = resumeLinks.apiUrl // Hidden from output
|
||||
output.resumeEndpoint = resumeLinks.apiUrl
|
||||
}
|
||||
|
||||
return output
|
||||
@@ -576,9 +576,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
if (context.resumeLinks.uiUrl) {
|
||||
pauseOutput.url = context.resumeLinks.uiUrl
|
||||
}
|
||||
// if (context.resumeLinks.apiUrl) {
|
||||
// pauseOutput.apiUrl = context.resumeLinks.apiUrl
|
||||
// } // Hidden from output
|
||||
if (context.resumeLinks.apiUrl) {
|
||||
pauseOutput.resumeEndpoint = context.resumeLinks.apiUrl
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(context.inputFormat)) {
|
||||
|
||||
@@ -226,10 +226,27 @@ export function getBlockOutputs(
|
||||
}
|
||||
|
||||
if (blockType === 'human_in_the_loop') {
|
||||
// For human_in_the_loop, only expose url (inputFormat fields are only available after resume)
|
||||
return {
|
||||
const hitlOutputs: Record<string, any> = {
|
||||
url: { type: 'string', description: 'Resume UI URL' },
|
||||
resumeEndpoint: {
|
||||
type: 'string',
|
||||
description: 'Resume API endpoint URL for direct curl requests',
|
||||
},
|
||||
}
|
||||
|
||||
const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value)
|
||||
|
||||
for (const field of normalizedInputFormat) {
|
||||
const fieldName = field?.name?.trim()
|
||||
if (!fieldName) continue
|
||||
|
||||
hitlOutputs[fieldName] = {
|
||||
type: (field?.type || 'any') as any,
|
||||
description: `Field from resume form`,
|
||||
}
|
||||
}
|
||||
|
||||
return hitlOutputs
|
||||
}
|
||||
|
||||
if (blockType === 'approval') {
|
||||
|
||||
@@ -538,15 +538,15 @@ export class PauseResumeManager {
|
||||
|
||||
mergedOutput.resume = mergedOutput.resume ?? mergedResponse.resume
|
||||
|
||||
// Preserve url from resume links (apiUrl hidden from output)
|
||||
// Preserve url and resumeEndpoint from resume links
|
||||
const resumeLinks = mergedOutput.resume ?? mergedResponse.resume
|
||||
if (resumeLinks && typeof resumeLinks === 'object') {
|
||||
if (resumeLinks.uiUrl) {
|
||||
mergedOutput.url = resumeLinks.uiUrl
|
||||
}
|
||||
// if (resumeLinks.apiUrl) {
|
||||
// mergedOutput.apiUrl = resumeLinks.apiUrl
|
||||
// } // Hidden from output
|
||||
if (resumeLinks.apiUrl) {
|
||||
mergedOutput.resumeEndpoint = resumeLinks.apiUrl
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(submissionPayload)) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { requestTool } from './request'
|
||||
import { webhookRequestTool } from './webhook_request'
|
||||
|
||||
export const httpRequestTool = requestTool
|
||||
export { webhookRequestTool }
|
||||
|
||||
@@ -17,3 +17,10 @@ export interface RequestResponse extends ToolResponse {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookRequestParams {
|
||||
url: string
|
||||
body?: any
|
||||
secret?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
130
apps/sim/tools/http/webhook_request.ts
Normal file
130
apps/sim/tools/http/webhook_request.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createHmac } from 'crypto'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { RequestResponse, WebhookRequestParams } from './types'
|
||||
|
||||
/**
|
||||
* Generates HMAC-SHA256 signature for webhook payload
|
||||
*/
|
||||
function generateSignature(secret: string, timestamp: number, body: string): string {
|
||||
const signatureBase = `${timestamp}.${body}`
|
||||
return createHmac('sha256', secret).update(signatureBase).digest('hex')
|
||||
}
|
||||
|
||||
export const webhookRequestTool: ToolConfig<WebhookRequestParams, RequestResponse> = {
|
||||
id: 'webhook_request',
|
||||
name: 'Webhook Request',
|
||||
description: 'Send a webhook request with automatic headers and optional HMAC signing',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
url: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The webhook URL to send the request to',
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
description: 'JSON payload to send',
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
description: 'Optional secret for HMAC-SHA256 signature',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Additional headers to include',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: WebhookRequestParams) => params.url,
|
||||
|
||||
method: () => 'POST',
|
||||
|
||||
headers: (params: WebhookRequestParams) => {
|
||||
const timestamp = Date.now()
|
||||
const deliveryId = uuidv4()
|
||||
|
||||
// Start with webhook-specific headers
|
||||
const webhookHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Timestamp': timestamp.toString(),
|
||||
'X-Delivery-ID': deliveryId,
|
||||
'Idempotency-Key': deliveryId,
|
||||
}
|
||||
|
||||
// Add signature if secret is provided
|
||||
if (params.secret) {
|
||||
const bodyString =
|
||||
typeof params.body === 'string' ? params.body : JSON.stringify(params.body || {})
|
||||
const signature = generateSignature(params.secret, timestamp, bodyString)
|
||||
webhookHeaders['X-Webhook-Signature'] = `t=${timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
// Merge with user-provided headers (user headers take precedence)
|
||||
const userHeaders = params.headers || {}
|
||||
|
||||
return { ...webhookHeaders, ...userHeaders }
|
||||
},
|
||||
|
||||
body: (params: WebhookRequestParams) => params.body,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key] = value
|
||||
})
|
||||
|
||||
const data = await (contentType.includes('application/json')
|
||||
? response.json()
|
||||
: response.text())
|
||||
|
||||
// Check if this is a proxy response
|
||||
if (
|
||||
contentType.includes('application/json') &&
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
data.data !== undefined &&
|
||||
data.status !== undefined
|
||||
) {
|
||||
return {
|
||||
success: data.success,
|
||||
output: {
|
||||
data: data.data,
|
||||
status: data.status,
|
||||
headers: data.headers || {},
|
||||
},
|
||||
error: data.success ? undefined : data.error,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
output: {
|
||||
data,
|
||||
status: response.status,
|
||||
headers,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
data: {
|
||||
type: 'json',
|
||||
description: 'Response data from the webhook endpoint',
|
||||
},
|
||||
status: {
|
||||
type: 'number',
|
||||
description: 'HTTP status code',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Response headers',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -376,7 +376,7 @@ import {
|
||||
greptileStatusTool,
|
||||
} from '@/tools/greptile'
|
||||
import { guardrailsValidateTool } from '@/tools/guardrails'
|
||||
import { httpRequestTool } from '@/tools/http'
|
||||
import { httpRequestTool, webhookRequestTool } from '@/tools/http'
|
||||
import {
|
||||
hubspotCreateCompanyTool,
|
||||
hubspotCreateContactTool,
|
||||
@@ -1415,6 +1415,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
browser_use_run_task: browserUseRunTaskTool,
|
||||
openai_embeddings: openAIEmbeddingsTool,
|
||||
http_request: httpRequestTool,
|
||||
webhook_request: webhookRequestTool,
|
||||
huggingface_chat: huggingfaceChatTool,
|
||||
llm_chat: llmChatTool,
|
||||
function_execute: functionExecuteTool,
|
||||
|
||||
Reference in New Issue
Block a user