Feat/Add API Trigger and Webhooks in Agent Builder (#83)

* feat: add api trigger node for agent runs

* feat: add webhook node

* Execute webhook nodes post workflow run

* Add hint to go to API keys
This commit is contained in:
Abhishek 2025-12-22 14:08:30 +05:30 committed by GitHub
parent 4ddb144dd0
commit 55b727a872
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3667 additions and 494 deletions

View file

@ -1,17 +1,24 @@
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
import { Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
type NodeTypeConfig = {
type: NodeType;
label: string;
description: string;
icon: LucideIcon;
};
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
};
const NODE_TYPES = [
const NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.START_CALL,
label: 'Start Call',
@ -32,14 +39,72 @@ const NODE_TYPES = [
}
];
const GLOBAL_NODE_TYPES = [
const GLOBAL_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.GLOBAL_NODE,
label: 'Global Node',
description: 'Create a global node',
icon: Globe
}
]
];
const TRIGGER_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.TRIGGER,
label: 'API Trigger',
description: 'Enable API-based call triggering',
icon: Webhook
}
];
const WEBHOOK_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.WEBHOOK,
label: 'Webhook',
description: 'Send HTTP request after workflow completion',
icon: Link2
}
];
function NodeSection({
title,
nodes,
onNodeSelect
}: {
title: string;
nodes: NodeTypeConfig[];
onNodeSelect: (nodeType: NodeType) => void;
}) {
return (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
<div className="space-y-2">
{nodes.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-5 w-5" />
</div>
<div className="flex flex-col items-start text-left min-w-0">
<span className="font-medium text-sm">{node.label}</span>
<span className="text-xs text-muted-foreground whitespace-normal">
{node.description}
</span>
</div>
</div>
</Button>
))}
</div>
</div>
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
useEffect(() => {
@ -58,58 +123,38 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
className={`fixed z-51 right-0 top-0 h-full w-80 bg-background shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<div className="p-4 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold">Add New Node</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<h1 className="text-sm text-muted-foreground mb-2">Agent Nodes</h1>
<div className="space-y-6">
<NodeSection
title="Triggers"
nodes={TRIGGER_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<div className="space-y-2">
{NODE_TYPES.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
<NodeSection
title="Agent Nodes"
nodes={NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<h1 className="text-sm text-muted-foreground mb-2">Global Nodes</h1>
<NodeSection
title="Global Nodes"
nodes={GLOBAL_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<div className="space-y-2">
{GLOBAL_NODE_TYPES.map((node) => (
<Button
variant="outline"
className="w-full justify-start p-4 h-auto"
key={node.type}
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-muted-foreground">{node.description}</span>
</div>
</div>
</Button>
))}
<NodeSection
title="Integrations"
nodes={WEBHOOK_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
</div>
</div>
</div>

View file

@ -0,0 +1,234 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Check, Copy, Edit, Trash2Icon, Webhook } from "lucide-react";
import Link from "next/link";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface TriggerNodeEditFormProps {
name: string;
setName: (value: string) => void;
endpoint: string;
}
interface TriggerNodeProps extends NodeProps {
data: FlowNodeData;
}
export const TriggerNode = memo(({ data, selected, id }: TriggerNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [name, setName] = useState(data.name || "API Trigger");
// Generate trigger_path if not present (should be done on node creation)
const [triggerPath] = useState(() => data.trigger_path ?? crypto.randomUUID());
// Get backend URL from environment
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
// Copy state for button feedback
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSave = async () => {
handleSaveNodeData({
...data,
name,
trigger_path: triggerPath,
});
setOpen(false);
// Save the workflow after updating node data
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setName(data.name || "API Trigger");
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setName(data.name || "API Trigger");
}
}, [data, open]);
// Ensure trigger_path is saved on initial render if it was generated
useEffect(() => {
if (!data.trigger_path && triggerPath) {
handleSaveNodeData({
...data,
trigger_path: triggerPath,
name: data.name || "API Trigger",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || "API Trigger"}
icon={<Webhook />}
nodeType="trigger"
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">API Endpoint:</p>
<div className="flex items-center gap-1">
<code className="text-xs break-all bg-muted px-1 py-0.5 rounded flex-1">
{endpoint}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit API Trigger"
onSave={handleSave}
>
{open && (
<TriggerNodeEditForm
name={name}
setName={setName}
endpoint={endpoint}
/>
)}
</NodeEditDialog>
</>
);
});
const TriggerNodeEditForm = ({
name,
setName,
endpoint,
}: TriggerNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
const [curlCopied, setCurlCopied] = useState(false);
const handleCopyEndpoint = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const curlExample = `curl -X POST "${endpoint}" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone_number": "+1234567890", "initial_context": {}}'`;
const handleCopyCurl = async () => {
await navigator.clipboard.writeText(curlExample);
setCurlCopied(true);
setTimeout(() => setCurlCopied(false), 2000);
};
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this trigger.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>API Endpoint</Label>
<Label className="text-xs text-muted-foreground">
Use this endpoint to trigger calls via API. Requires an API key in the X-API-Key header.{" "}
<Link href="/api-keys" target="_blank" className="text-primary underline hover:no-underline">
Get your API key
</Link>
</Label>
<div className="flex items-center gap-2">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{endpoint}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopyEndpoint}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>Example Request</Label>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded overflow-x-auto whitespace-pre-wrap">
{curlExample}
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2"
onClick={handleCopyCurl}
>
{curlCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
);
};
TriggerNode.displayName = "TriggerNode";

View file

@ -0,0 +1,691 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { AlertCircle, Check, Circle, Copy, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import {
createCredentialApiV1CredentialsPost,
listCredentialsApiV1CredentialsGet,
} from "@/client";
import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface WebhookNodeProps extends NodeProps {
data: FlowNodeData;
}
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface CustomHeader {
key: string;
value: string;
}
export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
const { getAccessToken } = useAuth();
// Form state
const [name, setName] = useState(data.name || "Webhook");
const [enabled, setEnabled] = useState(data.enabled ?? true);
const [httpMethod, setHttpMethod] = useState<HttpMethod>(data.http_method || "POST");
const [endpointUrl, setEndpointUrl] = useState(data.endpoint_url || "");
const [credentialUuid, setCredentialUuid] = useState(data.credential_uuid || "");
const [customHeaders, setCustomHeaders] = useState<CustomHeader[]>(
data.custom_headers || []
);
const [payloadTemplate, setPayloadTemplate] = useState(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Credentials state
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
const [credentialsLoading, setCredentialsLoading] = useState(false);
// Fetch credentials when dialog opens
const fetchCredentials = useCallback(async () => {
setCredentialsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await listCredentialsApiV1CredentialsGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.error) {
console.error("Failed to fetch credentials:", response.error);
setCredentials([]);
return;
}
if (response.data) {
setCredentials(response.data);
}
} catch (error) {
console.error("Failed to fetch credentials:", error);
setCredentials([]);
} finally {
setCredentialsLoading(false);
}
}, [getAccessToken]);
const handleSave = async () => {
let parsedPayload = {};
try {
parsedPayload = JSON.parse(payloadTemplate);
} catch {
// Keep empty object if invalid JSON
}
handleSaveNodeData({
...data,
name,
enabled,
http_method: httpMethod,
endpoint_url: endpointUrl,
credential_uuid: credentialUuid || undefined,
custom_headers: customHeaders.filter((h) => h.key && h.value),
payload_template: parsedPayload,
});
setOpen(false);
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setName(data.name || "Webhook");
setEnabled(data.enabled ?? true);
setHttpMethod(data.http_method || "POST");
setEndpointUrl(data.endpoint_url || "");
setCredentialUuid(data.credential_uuid || "");
setCustomHeaders(data.custom_headers || []);
setPayloadTemplate(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Fetch credentials when dialog opens
fetchCredentials();
}
setOpen(newOpen);
};
useEffect(() => {
if (open) {
setName(data.name || "Webhook");
setEnabled(data.enabled ?? true);
setHttpMethod(data.http_method || "POST");
setEndpointUrl(data.endpoint_url || "");
setCredentialUuid(data.credential_uuid || "");
setCustomHeaders(data.custom_headers || []);
setPayloadTemplate(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
}
}, [data, open]);
const truncateUrl = (url: string, maxLength: number = 30) => {
if (!url) return "Not configured";
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || "Webhook"}
icon={<Link2 />}
nodeType="webhook"
onDoubleClick={() => handleOpenChange(true)}
nodeId={id}
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{data.http_method || "POST"}
</span>
<span className="text-xs text-muted-foreground truncate flex-1">
{truncateUrl(data.endpoint_url || "")}
</span>
</div>
<div className="flex items-center gap-1.5">
<Circle
className={`h-2 w-2 ${data.enabled !== false ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<span className="text-xs text-muted-foreground">
{data.enabled !== false ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => handleOpenChange(true)} variant="outline" size="icon">
<Edit />
</Button>
<Button onClick={handleDeleteNode} variant="outline" size="icon">
<Trash2Icon />
</Button>
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Edit Webhook"
onSave={handleSave}
>
{open && (
<WebhookNodeEditForm
name={name}
setName={setName}
enabled={enabled}
setEnabled={setEnabled}
httpMethod={httpMethod}
setHttpMethod={setHttpMethod}
endpointUrl={endpointUrl}
setEndpointUrl={setEndpointUrl}
credentialUuid={credentialUuid}
setCredentialUuid={setCredentialUuid}
credentials={credentials}
credentialsLoading={credentialsLoading}
onRefreshCredentials={fetchCredentials}
getAccessToken={getAccessToken}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
payloadTemplate={payloadTemplate}
setPayloadTemplate={setPayloadTemplate}
/>
)}
</NodeEditDialog>
</>
);
});
interface WebhookNodeEditFormProps {
name: string;
setName: (value: string) => void;
enabled: boolean;
setEnabled: (value: boolean) => void;
httpMethod: HttpMethod;
setHttpMethod: (value: HttpMethod) => void;
endpointUrl: string;
setEndpointUrl: (value: string) => void;
credentialUuid: string;
setCredentialUuid: (value: string) => void;
credentials: CredentialResponse[];
credentialsLoading: boolean;
onRefreshCredentials: () => Promise<void>;
getAccessToken: () => Promise<string>;
customHeaders: CustomHeader[];
setCustomHeaders: (value: CustomHeader[]) => void;
payloadTemplate: string;
setPayloadTemplate: (value: string) => void;
}
const WebhookNodeEditForm = ({
name,
setName,
enabled,
setEnabled,
httpMethod,
setHttpMethod,
endpointUrl,
setEndpointUrl,
credentialUuid,
setCredentialUuid,
credentials,
credentialsLoading,
onRefreshCredentials,
getAccessToken,
customHeaders,
setCustomHeaders,
payloadTemplate,
setPayloadTemplate,
}: WebhookNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
// Add Credential Dialog state
const [isAddCredentialOpen, setIsAddCredentialOpen] = useState(false);
const [newCredName, setNewCredName] = useState("");
const [newCredDescription, setNewCredDescription] = useState("");
const [newCredType, setNewCredType] = useState<WebhookCredentialType>("bearer_token");
const [newCredData, setNewCredData] = useState<Record<string, string>>({});
const [isCreatingCredential, setIsCreatingCredential] = useState(false);
const [credentialError, setCredentialError] = useState<string | null>(null);
const handleCreateCredential = async () => {
if (!newCredName.trim()) return;
setIsCreatingCredential(true);
setCredentialError(null);
try {
const accessToken = await getAccessToken();
const response = await createCredentialApiV1CredentialsPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: {
name: newCredName,
description: newCredDescription || undefined,
credential_type: newCredType,
credential_data: newCredData,
},
});
if (response.error) {
const errorDetail = (response.error as { detail?: string })?.detail
|| "Failed to create credential";
setCredentialError(errorDetail);
return;
}
if (response.data) {
// Refresh credentials list
await onRefreshCredentials();
// Select the newly created credential
setCredentialUuid(response.data.uuid);
// Close dialog and reset form
setIsAddCredentialOpen(false);
setNewCredName("");
setNewCredDescription("");
setNewCredType("bearer_token");
setNewCredData({});
setCredentialError(null);
}
} catch (error) {
console.error("Failed to create credential:", error);
setCredentialError(
error instanceof Error ? error.message : "An unexpected error occurred"
);
} finally {
setIsCreatingCredential(false);
}
};
const handleAddCredentialDialogChange = (open: boolean) => {
setIsAddCredentialOpen(open);
if (!open) {
// Reset error when closing dialog
setCredentialError(null);
}
};
const getCredentialDataFields = (type: WebhookCredentialType) => {
switch (type) {
case "api_key":
return [
{ key: "header_name", label: "Header Name", placeholder: "X-API-Key" },
{ key: "api_key", label: "API Key", placeholder: "your-api-key", isSecret: true },
];
case "bearer_token":
return [
{ key: "token", label: "Token", placeholder: "your-bearer-token", isSecret: true },
];
case "basic_auth":
return [
{ key: "username", label: "Username", placeholder: "username" },
{ key: "password", label: "Password", placeholder: "password", isSecret: true },
];
case "custom_header":
return [
{ key: "header_name", label: "Header Name", placeholder: "X-Custom-Header" },
{ key: "header_value", label: "Header Value", placeholder: "header-value", isSecret: true },
];
default:
return [];
}
};
const handleCopyPayload = async () => {
await navigator.clipboard.writeText(payloadTemplate);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const addHeader = () => {
setCustomHeaders([...customHeaders, { key: "", value: "" }]);
};
const updateHeader = (index: number, field: "key" | "value", value: string) => {
const newHeaders = [...customHeaders];
newHeaders[index] = { ...newHeaders[index], [field]: value };
setCustomHeaders(newHeaders);
};
const removeHeader = (index: number) => {
setCustomHeaders(customHeaders.filter((_, i) => i !== index));
};
const availableVariables = [
{ name: "workflow_run_id", description: "Unique ID of the workflow run" },
{ name: "workflow_run_name", description: "Name of the workflow run" },
{ name: "workflow_id", description: "ID of the workflow" },
{ name: "workflow_name", description: "Name of the workflow" },
{ name: "initial_context.*", description: "Initial context variables" },
{ name: "gathered_context.*", description: "Extracted variables" },
{ name: "cost_info.call_duration_seconds", description: "Call duration" },
{ name: "completed_at", description: "Completion timestamp" },
{ name: "disposition_code", description: "Final disposition code" },
{ name: "recording_url", description: "Call recording URL" },
{ name: "transcript_url", description: "Transcript URL" },
];
return (
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">Basic</TabsTrigger>
<TabsTrigger value="auth">Auth</TabsTrigger>
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="payload">Payload</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this webhook.
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="enabled">Enabled</Label>
<Label className="text-xs text-muted-foreground ml-2">
Whether this webhook is active.
</Label>
</div>
<div className="grid gap-2">
<Label>HTTP Method</Label>
<Select value={httpMethod} onValueChange={(v) => setHttpMethod(v as HttpMethod)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<Label className="text-xs text-muted-foreground">
The URL to send the webhook request to.
</Label>
<Input
value={endpointUrl}
onChange={(e) => setEndpointUrl(e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
</TabsContent>
<TabsContent value="auth" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Credential</Label>
<Label className="text-xs text-muted-foreground">
Select a credential for authentication, or leave empty for no auth.
</Label>
<div className="flex gap-2">
<Select
value={credentialUuid || "none"}
onValueChange={(v) => setCredentialUuid(v === "none" ? "" : v)}
disabled={credentialsLoading}
>
<SelectTrigger className="flex-1">
{credentialsLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
) : (
<SelectValue placeholder="No authentication" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No authentication</SelectItem>
{credentials.map((cred) => (
<SelectItem key={cred.uuid} value={cred.uuid}>
{cred.name} ({cred.credential_type})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => setIsAddCredentialOpen(true)}
title="Add new credential"
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
{credentials.length === 0 && !credentialsLoading && (
<div className="p-3 border rounded-md bg-muted/20">
<p className="text-sm text-muted-foreground">
No credentials found. Click the + button to create one.
</p>
</div>
)}
{/* Add Credential Dialog */}
<Dialog open={isAddCredentialOpen} onOpenChange={handleAddCredentialDialogChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Credential</DialogTitle>
<DialogDescription>
Create a new credential for webhook authentication.
</DialogDescription>
</DialogHeader>
{/* Error display */}
{credentialError && (
<div className="flex items-start gap-2 p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>{credentialError}</span>
</div>
)}
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="cred-name">Name *</Label>
<Input
id="cred-name"
value={newCredName}
onChange={(e) => setNewCredName(e.target.value)}
placeholder="My API Key"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cred-description">Description</Label>
<Input
id="cred-description"
value={newCredDescription}
onChange={(e) => setNewCredDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="grid gap-2">
<Label>Credential Type</Label>
<Select
value={newCredType}
onValueChange={(v) => {
setNewCredType(v as WebhookCredentialType);
setNewCredData({});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bearer_token">Bearer Token</SelectItem>
<SelectItem value="api_key">API Key</SelectItem>
<SelectItem value="basic_auth">Basic Auth</SelectItem>
<SelectItem value="custom_header">Custom Header</SelectItem>
</SelectContent>
</Select>
</div>
{getCredentialDataFields(newCredType).map((field) => (
<div key={field.key} className="grid gap-2">
<Label htmlFor={`cred-${field.key}`}>{field.label}</Label>
<Input
id={`cred-${field.key}`}
type={field.isSecret ? "password" : "text"}
value={newCredData[field.key] || ""}
onChange={(e) =>
setNewCredData((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
placeholder={field.placeholder}
/>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddCredentialOpen(false)}
disabled={isCreatingCredential}
>
Cancel
</Button>
<Button
onClick={handleCreateCredential}
disabled={!newCredName.trim() || isCreatingCredential}
>
{isCreatingCredential ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="headers" className="space-y-4 mt-4">
<div className="grid gap-2">
<Label>Custom Headers</Label>
<Label className="text-xs text-muted-foreground">
Add custom headers to include in the webhook request.
</Label>
{customHeaders.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
placeholder="Header name"
value={header.key}
onChange={(e) => updateHeader(index, "key", e.target.value)}
className="flex-1"
/>
<Input
placeholder="Header value"
value={header.value}
onChange={(e) => updateHeader(index, "value", e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={() => removeHeader(index)}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addHeader} className="w-fit">
<PlusIcon className="h-4 w-4 mr-1" /> Add Header
</Button>
</div>
</TabsContent>
<TabsContent value="payload" className="space-y-4 mt-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Payload Template (JSON)</Label>
<Button
variant="outline"
size="sm"
onClick={handleCopyPayload}
>
{copied ? (
<Check className="h-4 w-4 mr-1" />
) : (
<Copy className="h-4 w-4 mr-1" />
)}
Copy
</Button>
</div>
<Label className="text-xs text-muted-foreground">
Define the JSON payload. Use {"{{variable}}"} syntax for dynamic values.
</Label>
<Textarea
value={payloadTemplate}
onChange={(e) => setPayloadTemplate(e.target.value)}
className="min-h-[200px] font-mono text-sm"
placeholder='{"call_id": "{{workflow_run_id}}"}'
/>
</div>
<div className="border rounded-md p-3 bg-muted/20">
<Label className="text-sm font-medium">Available Variables</Label>
<div className="mt-2 space-y-1">
{availableVariables.map((v) => (
<div key={v.name} className="text-xs">
<code className="bg-muted px-1 py-0.5 rounded">
{`{{${v.name}}}`}
</code>
<span className="text-muted-foreground ml-2">{v.description}</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
};
WebhookNode.displayName = "WebhookNode";

View file

@ -12,7 +12,7 @@ interface NodeContentProps {
hovered_through_edge?: boolean;
title: string;
icon: ReactNode;
nodeType?: 'start' | 'agent' | 'end' | 'global';
nodeType?: 'start' | 'agent' | 'end' | 'global' | 'trigger' | 'webhook';
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
@ -32,6 +32,10 @@ const getNodeTypeBadge = (nodeType?: string) => {
return { label: 'End Node', className: 'bg-rose-500 text-white' };
case 'global':
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
case 'trigger':
return { label: 'API Trigger', className: 'bg-purple-500 text-white' };
case 'webhook':
return { label: 'Webhook', className: 'bg-indigo-500 text-white' };
default:
return { label: 'Node', className: 'bg-zinc-500 text-white' };
}
@ -50,6 +54,7 @@ export const NodeContent = ({
children,
className = "",
onDoubleClick,
nodeId,
}: NodeContentProps) => {
const badge = getNodeTypeBadge(nodeType);
@ -80,6 +85,11 @@ export const NodeContent = ({
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground truncate">
{title}
{nodeId && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
#{nodeId}
</span>
)}
</h3>
</div>
</div>

View file

@ -2,3 +2,5 @@ export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './StartCall';
export * from './TriggerNode';
export * from './WebhookNode';

View file

@ -2,7 +2,9 @@ export enum NodeType {
START_CALL = 'startCall',
AGENT_NODE = 'agentNode',
END_CALL = 'endCall',
GLOBAL_NODE = 'globalNode'
GLOBAL_NODE = 'globalNode',
TRIGGER = 'trigger',
WEBHOOK = 'webhook'
}
export type FlowNodeData = {
@ -24,6 +26,20 @@ export type FlowNodeData = {
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
// Trigger node specific
trigger_path?: string;
// Webhook node specific
enabled?: boolean;
http_method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
endpoint_url?: string;
credential_uuid?: string;
custom_headers?: Array<{ key: string; value: string }>;
payload_template?: Record<string, unknown>;
retry_config?: {
enabled: boolean;
max_retries: number;
retry_delay_seconds: number;
};
}
export type FlowNode = {
@ -84,3 +100,15 @@ export type ExtractionVariable = {
prompt?: string;
};
// Credential types for webhook authentication
export type CredentialType = 'none' | 'api_key' | 'bearer_token' | 'basic_auth' | 'custom_header';
export interface Credential {
uuid: string;
name: string;
description?: string;
credential_type: CredentialType;
created_at: string;
updated_at: string;
}