import { NodeProps, NodeToolbar, Position } from "@xyflow/react"; import * as LucideIcons from "lucide-react"; import { Check, Circle, Copy, Edit, type LucideIcon, Trash2Icon } from "lucide-react"; import Link from "next/link"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext"; import type { NodeSpec } from "@/client/types.gen"; import { DocumentBadges } from "@/components/flow/DocumentBadges"; import { NodeEditForm, useNodeSpecs } from "@/components/flow/renderer"; import { ToolBadges } from "@/components/flow/ToolBadges"; import { FlowNodeData } from "@/components/flow/types"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation"; import { useAppConfig } from "@/context/AppConfigContext"; import { cn } from "@/lib/utils"; import { resolveWebhookBaseUrl } from "@/lib/webhookUrl"; import { NodeContent } from "./common/NodeContent"; import { NodeEditDialog } from "./common/NodeEditDialog"; import { useNodeHandlers } from "./common/useNodeHandlers"; // ─── Static per-spec UI maps ────────────────────────────────────────────── // Small lookups indexed by spec.name. Keeping these in the renderer (not // the spec) avoids leaking UI concerns into the backend schema. Add an // entry when registering a new node type. type NodeStyleVariant = | "start" | "agent" | "end" | "global" | "trigger" | "webhook" | "qa" | "integration"; const STYLE_VARIANT_BY_SPEC: Record = { startCall: "start", agentNode: "agent", endCall: "end", globalNode: "global", trigger: "trigger", webhook: "webhook", qa: "qa", }; const HANDLES_BY_SPEC: Record = { startCall: { source: true, target: false }, agentNode: { source: true, target: true }, endCall: { source: false, target: true }, globalNode: { source: false, target: false }, trigger: { source: false, target: false }, webhook: { source: false, target: false }, qa: { source: false, target: false }, }; const DOC_URL_BY_SPEC: Record = { startCall: NODE_DOCUMENTATION_URLS.startCall, agentNode: NODE_DOCUMENTATION_URLS.agent, endCall: NODE_DOCUMENTATION_URLS.endCall, globalNode: NODE_DOCUMENTATION_URLS.global, trigger: NODE_DOCUMENTATION_URLS.apiTrigger, webhook: NODE_DOCUMENTATION_URLS.webhook, qa: NODE_DOCUMENTATION_URLS.qaAnalysis, }; // ─── Helpers ────────────────────────────────────────────────────────────── function resolveIcon(name: string): LucideIcon { const icons = LucideIcons as unknown as Record; return icons[name] ?? Circle; } function seedValues( data: FlowNodeData, spec: NodeSpec, ): Record { const d = data as unknown as Record; const out: Record = {}; for (const prop of spec.properties) { out[prop.name] = d[prop.name] ?? prop.default ?? undefined; } return out; } interface TriggerEndpoints { production: string; test: string; } function buildTriggerEndpoints( triggerPath: string | undefined, baseUrl: string, ): TriggerEndpoints { if (!triggerPath) return { production: "", test: "" }; return { production: `${baseUrl}/api/v1/public/agent/${triggerPath}`, test: `${baseUrl}/api/v1/public/agent/test/${triggerPath}`, }; } function resolveIntegrationEnabled( spec: NodeSpec, data: FlowNodeData, ): boolean { for (const prop of spec.properties) { if (!prop.name.endsWith("enabled")) continue; const value = data[prop.name]; if (typeof value === "boolean") return value; } return true; } function resolveIntegrationSummary( spec: NodeSpec, data: FlowNodeData, ): string { for (const prop of spec.properties) { if ( prop.name === "name" || prop.name.endsWith("enabled") || /api[_-]?key|token|secret/i.test(prop.name) ) { continue; } const value = data[prop.name]; if (typeof value === "string" && value.trim().length > 0) { return value.length > 30 ? `${value.slice(0, 30)}...` : value; } if (typeof value === "number") { return String(value); } } return "Not configured"; } function getBadgeForSpec( spec: NodeSpec | undefined, variant: NodeStyleVariant, ): { label: string; className: string } { if (!spec) { return { label: "Node", className: "bg-zinc-500 text-white" }; } switch (variant) { case "start": return { label: "Start Node", className: "bg-emerald-500 text-white" }; case "agent": return { label: "Agent Node", className: "bg-blue-500 text-white" }; case "end": 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" }; case "qa": return { label: "QA Analysis", className: "bg-teal-500 text-white" }; case "integration": return { label: spec.display_name, className: "bg-cyan-600 text-white" }; } } // ─── Canvas preview dispatch ────────────────────────────────────────────── function CanvasPreview({ spec, data, onCopyTrigger, triggerCopied, onStaleTools, onStaleDocuments, }: { spec: NodeSpec; data: FlowNodeData; onCopyTrigger: () => void; triggerCopied: boolean; onStaleTools: (uuids: string[]) => void; onStaleDocuments: (uuids: string[]) => void; }) { const { config: appConfig } = useAppConfig(); if (spec.name === "trigger") { const endpoint = buildTriggerEndpoints( data.trigger_path, resolveWebhookBaseUrl(appConfig?.tunnelUrl), ).production; return (

API Endpoint:

{endpoint || "Generating..."}
); } if (spec.name === "webhook") { const method = data.http_method || "POST"; const url = data.endpoint_url || ""; const enabled = data.enabled !== false; const truncated = !url ? "Not configured" : url.length > 30 ? url.slice(0, 30) + "..." : url; return (
{method} {truncated}
); } if (spec.name === "qa") { const llmSource = data.qa_use_workflow_llm !== false ? "Workflow LLM" : `${data.qa_provider || "openai"}/${data.qa_model || "gpt-4.1"}`; const enabled = data.qa_enabled !== false; return (
{llmSource}
); } if (spec.category === "integration") { const enabled = resolveIntegrationEnabled(spec, data); const destination = resolveIntegrationSummary(spec, data); return (
{destination}
); } // Default: prompt preview + tool/document badges (when spec declares them). const hasToolRefs = spec.properties.some((p) => p.type === "tool_refs"); const hasDocRefs = spec.properties.some((p) => p.type === "document_refs"); return ( <>

{data.prompt || "No prompt configured"}

{hasToolRefs && data.tool_uuids && data.tool_uuids.length > 0 && (
Tools:
)} {hasDocRefs && data.document_uuids && data.document_uuids.length > 0 && (
Documents:
)} ); } function StatusDot({ enabled }: { enabled: boolean }) { return (
{enabled ? "Enabled" : "Disabled"}
); } // ─── Trigger webhook URLs (test + production) — rendered inside the dialog ─ function buildCurl(endpoint: string): string { return `curl -X POST "${endpoint}" \\ -H "X-API-Key: YOUR_API_KEY" \\ -H "Content-Type: application/json" \\ -d '{"phone_number": "+1234567890", "initial_context": {}}'`; } function ClickToCopy({ value, children, className, title, }: { value: string; children: React.ReactNode; className?: string; title?: string; }) { const [copied, setCopied] = useState(false); const onCopy = async () => { if (!value) return; await navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( ); } function UrlPanel({ endpoint, helperText, }: { endpoint: string; helperText: string; }) { const curl = endpoint ? buildCurl(endpoint) : ""; return (
POST {endpoint || "Generating..."}

{helperText}

Example Request

                    {curl || "Generating..."}
                
); } function TriggerWebhookUrls({ endpoints }: { endpoints: TriggerEndpoints }) { return (

Webhook URLs

Test mode runs the latest draft so you can verify changes before publishing. Production runs the published agent. Both require an API key in the X-API-Key header.{" "} Get your API key

Test URL Production URL
); } // ─── GenericNode ────────────────────────────────────────────────────────── interface GenericNodeProps extends NodeProps { data: FlowNodeData; type: string; } export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps) => { // Per-type metadata that StartCall/EndCall used to set via `additionalData` // (is_start / is_end). Pulled from the spec name here. const additionalData = useMemo | undefined>(() => { const out: Record = {}; if (type === "startCall") out.is_start = true; if (type === "endCall") out.is_end = true; return Object.keys(out).length > 0 ? out : undefined; }, [type]); const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id, additionalData, }); const { saveWorkflow, tools, documents, recordings } = useWorkflow(); const { bySpecName } = useNodeSpecs(); const { config: appConfig } = useAppConfig(); const spec = bySpecName.get(type); const webhookBaseUrl = resolveWebhookBaseUrl(appConfig?.tunnelUrl); // ── Form state ───────────────────────────────────────────────────── // mcp_tool_filters is not a spec property, so seedValues won't carry it; // seed merges it back in alongside the spec-derived values. const seed = useCallback( () => spec ? { ...seedValues(data, spec), mcp_tool_filters: data.mcp_tool_filters } : {}, [data, spec], ); const [values, setValues] = useState>(seed); // Re-seed once the spec arrives (initial fetch race). useEffect(() => { if (spec && Object.keys(values).length === 0) { setValues(seed()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [spec]); // ── Trigger auto-UUID + canvas copy state ────────────────────────── const [triggerCopied, setTriggerCopied] = useState(false); const handleCopyTrigger = useCallback(async () => { const endpoint = buildTriggerEndpoints(data.trigger_path, webhookBaseUrl).production; if (!endpoint) return; await navigator.clipboard.writeText(endpoint); setTriggerCopied(true); setTimeout(() => setTriggerCopied(false), 2000); }, [data.trigger_path, webhookBaseUrl]); // For trigger nodes without a path yet, generate one and persist. useEffect(() => { if (type !== "trigger") return; if (data.trigger_path) return; const newPath = crypto.randomUUID(); handleSaveNodeData({ ...data, trigger_path: newPath }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]); // ── Stale tool/document cleanup (was duplicated in Start/Agent) ───── const handleStaleTools = useCallback( async (staleUuids: string[]) => { const cleaned = (data.tool_uuids ?? []).filter( (u) => !staleUuids.includes(u), ); handleSaveNodeData({ ...data, tool_uuids: cleaned.length > 0 ? cleaned : undefined, }); await saveWorkflow(); }, [data, handleSaveNodeData, saveWorkflow], ); const handleStaleDocuments = useCallback( async (staleUuids: string[]) => { const cleaned = (data.document_uuids ?? []).filter( (u) => !staleUuids.includes(u), ); handleSaveNodeData({ ...data, document_uuids: cleaned.length > 0 ? cleaned : undefined, }); await saveWorkflow(); }, [data, handleSaveNodeData, saveWorkflow], ); // ── Dirty / save / open handlers ──────────────────────────────────── const propertyNames = useMemo( () => spec?.properties.map((p) => p.name) ?? [], [spec], ); const isDirty = useMemo(() => { if (!spec) return false; const baseline = seedValues(data, spec); if (propertyNames.some((n) => values[n] !== baseline[n])) return true; return ( JSON.stringify(values.mcp_tool_filters ?? {}) !== JSON.stringify(data.mcp_tool_filters ?? {}) ); }, [values, data, spec, propertyNames]); const handleSave = async () => { if (!spec) return; handleSaveNodeData({ ...data, ...(values as Partial), }); setOpen(false); await saveWorkflow(); }; const handleOpenChange = (newOpen: boolean) => { if (newOpen && spec) setValues(seed()); setOpen(newOpen); }; useEffect(() => { if (open && spec) setValues(seed()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, open]); // ── Render ────────────────────────────────────────────────────────── const styleVariant = STYLE_VARIANT_BY_SPEC[type] ?? (spec?.category === "integration" ? "integration" : "agent"); const handles = HANDLES_BY_SPEC[type] ?? (spec?.category === "integration" ? { source: false, target: false } : { source: true, target: true }); const badge = getBadgeForSpec(spec, styleVariant); const Icon = spec ? resolveIcon(spec.icon) : Circle; const docUrl = spec?.docs_url ?? DOC_URL_BY_SPEC[type]; const contentLabel = spec?.properties.some((p) => p.name === "prompt") ? "Prompt" : "Details"; // Edit dialog title: "Edit {display_name}". Webhook keeps the original // "Edit Webhook" wording — display_name is "Webhook" so it works out. const dialogTitle = spec ? `Edit ${spec.display_name}` : "Edit Node"; const fallbackTitle = spec?.display_name ?? "Node"; return ( <> } badgeLabel={badge.label} badgeClassName={badge.className} contentLabel={contentLabel} hasSourceHandle={handles.source} hasTargetHandle={handles.target} onDoubleClick={() => setOpen(true)} nodeId={id} > {spec && ( )}
{/* Start nodes can't be deleted (workflow always needs one). */} {type !== "startCall" && ( )}
{open && spec && (
| undefined) ?? {}, onMcpToolFiltersChange: (next) => setValues((prev) => ({ ...prev, mcp_tool_filters: Object.keys(next).length > 0 ? next : undefined, })), }} /> {type === "trigger" && ( )}
)}
); }); GenericNode.displayName = "GenericNode";