mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-22 11:51:04 +02:00
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:
parent
4ddb144dd0
commit
55b727a872
37 changed files with 3667 additions and 494 deletions
|
|
@ -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>
|
||||
|
|
|
|||
234
ui/src/components/flow/nodes/TriggerNode.tsx
Normal file
234
ui/src/components/flow/nodes/TriggerNode.tsx
Normal 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";
|
||||
691
ui/src/components/flow/nodes/WebhookNode.tsx
Normal file
691
ui/src/components/flow/nodes/WebhookNode.tsx
Normal 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";
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ export * from './AgentNode';
|
|||
export * from './EndCall';
|
||||
export * from './GlobalNode';
|
||||
export * from './StartCall';
|
||||
export * from './TriggerNode';
|
||||
export * from './WebhookNode';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue