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 { cn } from "@/lib/utils"; 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"; 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, ): TriggerEndpoints { if (!triggerPath) return { production: "", test: "" }; const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || (typeof window !== "undefined" ? window.location.origin : ""); return { production: `${backendUrl}/api/v1/public/agent/${triggerPath}`, test: `${backendUrl}/api/v1/public/agent/test/${triggerPath}`, }; } // ─── 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; }) { if (spec.name === "trigger") { const endpoint = buildTriggerEndpoints(data.trigger_path).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}
); } // 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 spec = bySpecName.get(type); // ── Form state ───────────────────────────────────────────────────── const [values, setValues] = useState>(() => spec ? seedValues(data, spec) : {}, ); // Re-seed once the spec arrives (initial fetch race). useEffect(() => { if (spec && Object.keys(values).length === 0) { setValues(seedValues(data, spec)); } // 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).production; if (!endpoint) return; await navigator.clipboard.writeText(endpoint); setTriggerCopied(true); setTimeout(() => setTriggerCopied(false), 2000); }, [data.trigger_path]); // 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); return propertyNames.some((n) => values[n] !== baseline[n]); }, [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(seedValues(data, spec)); setOpen(newOpen); }; useEffect(() => { if (open && spec) setValues(seedValues(data, spec)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, open]); // ── Render ────────────────────────────────────────────────────────── const styleVariant = STYLE_VARIANT_BY_SPEC[type]; const handles = HANDLES_BY_SPEC[type] ?? { source: true, target: true }; const Icon = spec ? resolveIcon(spec.icon) : Circle; const docUrl = DOC_URL_BY_SPEC[type]; // 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 ( <> } nodeType={styleVariant} 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 && (
{type === "trigger" && ( )}
)}
); }); GenericNode.displayName = "GenericNode";