feat: user defined custom tools as part of workflow execution (#94)

* feat: add custom tools functionality

* Show tools in nodes

* integrate tool calling with pipeline engine
This commit is contained in:
Abhishek 2026-01-02 13:11:02 +05:30 committed by GitHub
parent cc2d3e70d2
commit 3e55af9256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 5483 additions and 6673 deletions

View file

@ -0,0 +1,64 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import { Badge } from "@/components/ui/badge";
import { useAuth } from "@/lib/auth";
interface ToolBadgesProps {
toolUuids: string[];
}
export function ToolBadges({ toolUuids }: ToolBadgesProps) {
const { getAccessToken } = useAuth();
const [tools, setTools] = useState<ToolResponse[]>([]);
const fetchTools = useCallback(async () => {
try {
const accessToken = await getAccessToken();
const response = await listToolsApiV1ToolsGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.data) {
setTools(response.data);
}
} catch (error) {
console.error("Failed to fetch tools:", error);
}
}, [getAccessToken]);
useEffect(() => {
if (toolUuids.length > 0) {
fetchTools();
}
}, [toolUuids.length, fetchTools]);
const selectedTools = tools.filter((tool) => toolUuids.includes(tool.tool_uuid));
if (selectedTools.length === 0 && toolUuids.length > 0) {
// Still loading or tools not found
return (
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs">
Loading...
</Badge>
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{selectedTools.map((tool) => (
<Badge
key={tool.tool_uuid}
variant="outline"
className="text-xs"
>
{tool.name}
</Badge>
))}
</div>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import { ExternalLink, Globe, Loader2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { listToolsApiV1ToolsGet } from "@/client/sdk.gen";
import type { ToolResponse } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/lib/auth";
interface ToolSelectorProps {
value: string[];
onChange: (uuids: string[]) => void;
disabled?: boolean;
label?: string;
description?: string;
showLabel?: boolean;
}
export function ToolSelector({
value,
onChange,
disabled = false,
label = "Tools",
description = "Select tools that the agent can use during the conversation.",
showLabel = true,
}: ToolSelectorProps) {
const { getAccessToken } = useAuth();
const [tools, setTools] = useState<ToolResponse[]>([]);
const [loading, setLoading] = useState(false);
const fetchTools = useCallback(async () => {
setLoading(true);
try {
const accessToken = await getAccessToken();
const response = await listToolsApiV1ToolsGet({
headers: { Authorization: `Bearer ${accessToken}` },
query: { status: "active" },
});
if (response.error) {
console.error("Failed to fetch tools:", response.error);
setTools([]);
return;
}
if (response.data) {
setTools(response.data);
}
} catch (error) {
console.error("Failed to fetch tools:", error);
setTools([]);
} finally {
setLoading(false);
}
}, [getAccessToken]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
const handleToggle = (toolUuid: string, checked: boolean) => {
if (checked) {
onChange([...value, toolUuid]);
} else {
onChange(value.filter((id) => id !== toolUuid));
}
};
return (
<div className="grid gap-2">
{showLabel && (
<>
<Label>{label}</Label>
{description && (
<Label className="text-xs text-muted-foreground">
{description}
</Label>
)}
</>
)}
{loading ? (
<div className="flex items-center gap-2 p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">Loading tools...</span>
</div>
) : tools.length === 0 ? (
<div className="p-4 border rounded-md text-center">
<p className="text-sm text-muted-foreground mb-2">
No tools available.
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/tools" target="_blank">
<ExternalLink className="h-4 w-4 mr-2" />
Create a Tool
</Link>
</Button>
</div>
) : (
<div className="border rounded-md divide-y">
{tools.map((tool) => {
const isSelected = value.includes(tool.tool_uuid);
return (
<label
key={tool.tool_uuid}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/50 ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={(checked) => {
handleToggle(tool.tool_uuid, checked === true);
}}
/>
<div
className="w-6 h-6 rounded flex items-center justify-center shrink-0"
style={{
backgroundColor: tool.icon_color || "#3B82F6",
}}
>
<Globe className="h-3 w-3 text-white" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{tool.name}
</span>
{tool.description && (
<span className="text-xs text-muted-foreground truncate">
{tool.description}
</span>
)}
</div>
</label>
);
})}
<div className="p-2 bg-muted/30">
<Link
href="/tools"
target="_blank"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
Manage Tools
</Link>
</div>
</div>
)}
{value.length > 0 && (
<p className="text-xs text-muted-foreground">
{value.length} tool{value.length !== 1 ? "s" : ""} selected
</p>
)}
</div>
);
}

View file

@ -1,9 +1,11 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
import { Edit, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable,FlowNodeData } from "@/components/flow/types";
import { ToolBadges } from "@/components/flow/ToolBadges";
import { ToolSelector } from "@/components/flow/ToolSelector";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -30,6 +32,8 @@ interface AgentNodeEditFormProps {
setVariables: (vars: ExtractionVariable[]) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
toolUuids: string[];
setToolUuids: (value: string[]) => void;
}
interface AgentNodeProps extends NodeProps {
@ -50,6 +54,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
const handleSave = async () => {
handleSaveNodeData({
@ -61,6 +66,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
@ -79,6 +85,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setToolUuids(data.tool_uuids ?? []);
}
setOpen(newOpen);
};
@ -93,6 +100,7 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setToolUuids(data.tool_uuids ?? []);
}
}, [data, open]);
@ -114,6 +122,15 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
{data.tool_uuids && data.tool_uuids.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/50">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Wrench className="h-3 w-3" />
<span>Tools:</span>
</div>
<ToolBadges toolUuids={data.tool_uuids} />
</div>
)}
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -151,6 +168,8 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
toolUuids={toolUuids}
setToolUuids={setToolUuids}
/>
)}
</NodeEditDialog>
@ -173,6 +192,8 @@ const AgentNodeEditForm = ({
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
toolUuids,
setToolUuids,
}: AgentNodeEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
@ -307,6 +328,15 @@ const AgentNodeEditForm = ({
</Button>
</div>
)}
{/* Tools Section */}
<div className="pt-4 border-t mt-4">
<ToolSelector
value={toolUuids}
onChange={setToolUuids}
description="Select tools that the agent can invoke during this conversation step."
/>
</div>
</div>
);
};

View file

@ -1,8 +1,10 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Play } from "lucide-react";
import { Edit, Play, Wrench } from "lucide-react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ToolBadges } from "@/components/flow/ToolBadges";
import { ToolSelector } from "@/components/flow/ToolSelector";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -31,6 +33,8 @@ interface StartCallEditFormProps {
setDelayedStart: (value: boolean) => void;
delayedStartDuration: number;
setDelayedStartDuration: (value: number) => void;
toolUuids: string[];
setToolUuids: (value: string[]) => void;
}
interface StartCallNodeProps extends NodeProps {
@ -52,6 +56,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
const [detectVoicemail, setDetectVoicemail] = useState(data.detect_voicemail ?? false);
const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false);
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
const handleSave = async () => {
handleSaveNodeData({
@ -62,7 +67,8 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
add_global_prompt: addGlobalPrompt,
detect_voicemail: detectVoicemail,
delayed_start: delayedStart,
delayed_start_duration: delayedStart ? delayedStartDuration : undefined
delayed_start_duration: delayedStart ? delayedStartDuration : undefined,
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
@ -81,6 +87,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
setDetectVoicemail(data.detect_voicemail ?? false);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
setToolUuids(data.tool_uuids ?? []);
}
setOpen(newOpen);
};
@ -95,6 +102,7 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
setDetectVoicemail(data.detect_voicemail ?? false);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
setToolUuids(data.tool_uuids ?? []);
}
}, [data, open]);
@ -115,6 +123,15 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
{data.tool_uuids && data.tool_uuids.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/50">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Wrench className="h-3 w-3" />
<span>Tools:</span>
</div>
<ToolBadges toolUuids={data.tool_uuids} />
</div>
)}
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
@ -147,6 +164,8 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
setDelayedStart={setDelayedStart}
delayedStartDuration={delayedStartDuration}
setDelayedStartDuration={setDelayedStartDuration}
toolUuids={toolUuids}
setToolUuids={setToolUuids}
/>
)}
</NodeEditDialog>
@ -168,7 +187,9 @@ const StartCallEditForm = ({
delayedStart,
setDelayedStart,
delayedStartDuration,
setDelayedStartDuration
setDelayedStartDuration,
toolUuids,
setToolUuids,
}: StartCallEditFormProps) => {
return (
<div className="grid gap-2">
@ -258,6 +279,15 @@ const StartCallEditForm = ({
</div>
)}
</div>
{/* Tools Section */}
<div className="pt-4 border-t mt-4">
<ToolSelector
value={toolUuids}
onChange={setToolUuids}
description="Select tools that the agent can invoke during this conversation step."
/>
</div>
</div>
);
};

View file

@ -1,36 +1,22 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { AlertCircle, Circle, Edit, Link2, Loader2, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { Circle, Edit, Link2, Trash2Icon } from "lucide-react";
import { memo, 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";
CredentialSelector,
type HttpMethod,
HttpMethodSelector,
KeyValueEditor,
type KeyValueItem,
} from "@/components/http";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { JsonEditor, validateJson } from "@/components/ui/json-editor";
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 { useAuth } from "@/lib/auth";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
@ -40,17 +26,9 @@ 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");
@ -58,41 +36,13 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
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[]>(
const [customHeaders, setCustomHeaders] = useState<KeyValueItem[]>(
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]);
// Validation state - only shown on save attempt
const [jsonError, setJsonError] = useState<string | null>(null);
const [endpointError, setEndpointError] = useState<string | null>(null);
@ -143,8 +93,6 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
// Clear any previous errors
setJsonError(null);
setEndpointError(null);
// Fetch credentials when dialog opens
fetchCredentials();
}
setOpen(newOpen);
};
@ -233,10 +181,6 @@ export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
setEndpointUrl={setEndpointUrl}
credentialUuid={credentialUuid}
setCredentialUuid={setCredentialUuid}
credentials={credentials}
credentialsLoading={credentialsLoading}
onRefreshCredentials={fetchCredentials}
getAccessToken={getAccessToken}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
payloadTemplate={payloadTemplate}
@ -259,16 +203,23 @@ interface WebhookNodeEditFormProps {
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;
customHeaders: KeyValueItem[];
setCustomHeaders: (value: KeyValueItem[]) => void;
payloadTemplate: string;
setPayloadTemplate: (value: string) => void;
}
const availableVariables = [
{ name: "workflow_run_id", description: "Unique ID 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: "recording_url", description: "Call recording URL" },
{ name: "transcript_url", description: "Transcript URL" },
];
const WebhookNodeEditForm = ({
name,
setName,
@ -280,130 +231,11 @@ const WebhookNodeEditForm = ({
setEndpointUrl,
credentialUuid,
setCredentialUuid,
credentials,
credentialsLoading,
onRefreshCredentials,
getAccessToken,
customHeaders,
setCustomHeaders,
payloadTemplate,
setPayloadTemplate,
}: WebhookNodeEditFormProps) => {
// 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 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_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: "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">
@ -432,18 +264,10 @@ const WebhookNodeEditForm = ({
<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>
<HttpMethodSelector
value={httpMethod}
onChange={setHttpMethod}
/>
</div>
<div className="grid gap-2">
@ -460,154 +284,10 @@ const WebhookNodeEditForm = ({
</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>
<CredentialSelector
value={credentialUuid}
onChange={setCredentialUuid}
/>
</TabsContent>
<TabsContent value="headers" className="space-y-4 mt-4">
@ -616,34 +296,13 @@ const WebhookNodeEditForm = ({
<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>
<KeyValueEditor
items={customHeaders}
onChange={setCustomHeaders}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addButtonText="Add Header"
/>
</div>
</TabsContent>

View file

@ -40,6 +40,8 @@ export type FlowNodeData = {
max_retries: number;
retry_delay_seconds: number;
};
// Tools - array of tool UUIDs that can be invoked by this node
tool_uuids?: string[];
}
export type FlowNode = {

View file

@ -0,0 +1,242 @@
"use client";
import { AlertCircle, Loader2 } from "lucide-react";
import { useState } from "react";
import { createCredentialApiV1CredentialsPost } from "@/client";
import { CredentialResponse, WebhookCredentialType } from "@/client/types.gen";
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 { useAuth } from "@/lib/auth";
interface CreateCredentialDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (credential: CredentialResponse) => void;
}
interface CredentialField {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
}
const getCredentialDataFields = (type: WebhookCredentialType): CredentialField[] => {
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 [];
}
};
export function CreateCredentialDialog({
open,
onOpenChange,
onCreated,
}: CreateCredentialDialogProps) {
const { getAccessToken } = useAuth();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [credentialType, setCredentialType] = useState<WebhookCredentialType>("bearer_token");
const [credentialData, setCredentialData] = useState<Record<string, string>>({});
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!name.trim()) return;
setIsCreating(true);
setError(null);
try {
const accessToken = await getAccessToken();
const response = await createCredentialApiV1CredentialsPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: {
name,
description: description || undefined,
credential_type: credentialType,
credential_data: credentialData,
},
});
if (response.error) {
const errorDetail = (response.error as { detail?: string })?.detail
|| "Failed to create credential";
setError(errorDetail);
return;
}
if (response.data) {
onCreated?.(response.data);
handleClose();
}
} catch (err) {
console.error("Failed to create credential:", err);
setError(
err instanceof Error ? err.message : "An unexpected error occurred"
);
} finally {
setIsCreating(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form
setName("");
setDescription("");
setCredentialType("bearer_token");
setCredentialData({});
setError(null);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null);
}
onOpenChange(newOpen);
};
const fields = getCredentialDataFields(credentialType);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Credential</DialogTitle>
<DialogDescription>
Create a new credential for authentication.
</DialogDescription>
</DialogHeader>
{error && (
<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>{error}</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={name}
onChange={(e) => setName(e.target.value)}
placeholder="My API Key"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cred-description">Description</Label>
<Input
id="cred-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="grid gap-2">
<Label>Credential Type</Label>
<Select
value={credentialType}
onValueChange={(v) => {
setCredentialType(v as WebhookCredentialType);
setCredentialData({});
}}
>
<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>
{fields.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={credentialData[field.key] || ""}
onChange={(e) =>
setCredentialData((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
placeholder={field.placeholder}
/>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!name.trim() || isCreating}
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { Loader2, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { listCredentialsApiV1CredentialsGet } from "@/client";
import { CredentialResponse } from "@/client/types.gen";
import { CreateCredentialDialog } from "@/components/http/create-credential-dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/lib/auth";
interface CredentialSelectorProps {
value: string;
onChange: (uuid: string) => void;
disabled?: boolean;
placeholder?: string;
label?: string;
description?: string;
showLabel?: boolean;
}
export function CredentialSelector({
value,
onChange,
disabled = false,
placeholder = "No authentication",
label = "Credential",
description = "Select a credential for authentication, or leave empty for no auth.",
showLabel = true,
}: CredentialSelectorProps) {
const { getAccessToken } = useAuth();
const [credentials, setCredentials] = useState<CredentialResponse[]>([]);
const [loading, setLoading] = useState(false);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const fetchCredentials = useCallback(async () => {
setLoading(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 {
setLoading(false);
}
}, [getAccessToken]);
useEffect(() => {
fetchCredentials();
}, [fetchCredentials]);
const handleCredentialCreated = async (credential: CredentialResponse) => {
await fetchCredentials();
onChange(credential.uuid);
};
return (
<div className="grid gap-2">
{showLabel && (
<>
<Label>{label}</Label>
{description && (
<Label className="text-xs text-muted-foreground">
{description}
</Label>
)}
</>
)}
<div className="flex gap-2">
<Select
value={value || "none"}
onValueChange={(v) => onChange(v === "none" ? "" : v)}
disabled={disabled || loading}
>
<SelectTrigger className="flex-1">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</div>
) : (
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{placeholder}</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={() => setIsAddDialogOpen(true)}
title="Add new credential"
disabled={disabled}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{credentials.length === 0 && !loading && (
<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>
)}
<CreateCredentialDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onCreated={handleCredentialCreated}
/>
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
interface HttpMethodSelectorProps {
value: HttpMethod;
onChange: (method: HttpMethod) => void;
disabled?: boolean;
}
const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
export function HttpMethodSelector({
value,
onChange,
disabled = false,
}: HttpMethodSelectorProps) {
return (
<Select
value={value}
onValueChange={(v) => onChange(v as HttpMethod)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -0,0 +1,5 @@
export { CreateCredentialDialog } from "./create-credential-dialog";
export { CredentialSelector } from "./credential-selector";
export { type HttpMethod, HttpMethodSelector } from "./http-method-selector";
export { KeyValueEditor, type KeyValueItem } from "./key-value-editor";
export { ParameterEditor, type ParameterType,type ToolParameter } from "./parameter-editor";

View file

@ -0,0 +1,85 @@
"use client";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface KeyValueItem {
key: string;
value: string;
}
interface KeyValueEditorProps {
items: KeyValueItem[];
onChange: (items: KeyValueItem[]) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
addButtonText?: string;
emptyMessage?: string;
disabled?: boolean;
}
export function KeyValueEditor({
items,
onChange,
keyPlaceholder = "Key",
valuePlaceholder = "Value",
addButtonText = "Add",
disabled = false,
}: KeyValueEditorProps) {
const addItem = () => {
onChange([...items, { key: "", value: "" }]);
};
const updateItem = (index: number, field: "key" | "value", value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
onChange(newItems);
};
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<Input
placeholder={keyPlaceholder}
value={item.key}
onChange={(e) => updateItem(index, "key", e.target.value)}
className="flex-1"
disabled={disabled}
/>
<Input
placeholder={valuePlaceholder}
value={item.value}
onChange={(e) => updateItem(index, "value", e.target.value)}
className="flex-1"
disabled={disabled}
/>
<Button
variant="outline"
size="icon"
onClick={() => removeItem(index)}
disabled={disabled}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addItem}
className="w-fit"
disabled={disabled}
>
<PlusIcon className="h-4 w-4 mr-1" /> {addButtonText}
</Button>
</div>
);
}

View file

@ -0,0 +1,167 @@
"use client";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
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";
export type ParameterType = "string" | "number" | "boolean";
export interface ToolParameter {
name: string;
type: ParameterType;
description: string;
required: boolean;
}
interface ParameterEditorProps {
parameters: ToolParameter[];
onChange: (parameters: ToolParameter[]) => void;
disabled?: boolean;
}
export function ParameterEditor({
parameters,
onChange,
disabled = false,
}: ParameterEditorProps) {
const addParameter = () => {
onChange([
...parameters,
{ name: "", type: "string", description: "", required: true },
]);
};
const updateParameter = (
index: number,
field: keyof ToolParameter,
value: string | boolean
) => {
const newParams = [...parameters];
newParams[index] = { ...newParams[index], [field]: value };
onChange(newParams);
};
const removeParameter = (index: number) => {
onChange(parameters.filter((_, i) => i !== index));
};
return (
<div className="space-y-4">
{parameters.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-md">
No parameters defined. Add a parameter to specify what data this tool needs.
</div>
)}
{parameters.map((param, index) => (
<div
key={index}
className="border rounded-lg p-4 space-y-3 bg-muted/20"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Parameter {index + 1}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => removeParameter(index)}
disabled={disabled}
className="h-8 w-8"
>
<Trash2Icon className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Label className="text-xs text-muted-foreground">
Name of the parameter, like &quot;order_id&quot; or &quot;customer_name&quot;
</Label>
<Input
placeholder="e.g., customer_name"
value={param.name}
onChange={(e) =>
updateParameter(index, "name", e.target.value)
}
disabled={disabled}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Type</Label>
<Label className="text-xs text-muted-foreground">
Type of the parameter, like &quot;string&quot; or &quot;number&quot; or &quot;boolean&quot;
</Label>
<Select
value={param.type}
onValueChange={(value: ParameterType) =>
updateParameter(index, "type", value)
}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Label className="text-xs text-muted-foreground">
Description of the parameter, which makes it easy for LLM to understand, like &quot;The ID of the Customer to fetch Order Details&quot;
</Label>
<Input
placeholder="Describe what this parameter is for..."
value={param.description}
onChange={(e) =>
updateParameter(index, "description", e.target.value)
}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id={`required-${index}`}
checked={param.required}
onCheckedChange={(checked) =>
updateParameter(index, "required", checked)
}
disabled={disabled}
/>
<Label htmlFor={`required-${index}`} className="text-sm">
Required
</Label>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addParameter}
className="w-fit"
disabled={disabled}
>
<PlusIcon className="h-4 w-4 mr-1" /> Add Parameter
</Button>
</div>
);
}

View file

@ -16,6 +16,7 @@ import {
Star,
TrendingUp,
Workflow,
Wrench,
Zap,
} from "lucide-react";
import Link from "next/link";
@ -108,6 +109,11 @@ export function AppSidebar() {
url: "/telephony-configurations",
icon: Phone,
},
{
title: "Tools",
url: "/tools",
icon: Wrench,
},
// {
// title: "Integrations",
// url: "/integrations",