feat: refactor node spec and add mcp tools (#244)

* refactor: carve out extraction panel

* refactor: create spec versions for node types

* refactor: create a GenericNode and remove custom nodes

* feat: add python and typescript sdk

* add dograh sdk

* fix: fetch draft workflow definition over published one

* fix: fix routes of SDKs to use code gen

* chore: remove doclink dependency to reduce image size

* chore: format files

* chore: bump pipecat

* feat: let mcp fetch archived workflows on demand

* chore: fix tests

* feat: add sdk documentation

* chore: change banner and add badge
This commit is contained in:
Abhishek 2026-04-21 07:56:16 +05:30 committed by GitHub
parent 0a61ef295f
commit 00a1a22b74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 14355 additions and 3554 deletions

View file

@ -1,415 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, FileText, Headset, PlusIcon, Trash2Icon, Wrench } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from "@/client/types.gen";
import { DocumentBadges } from "@/components/flow/DocumentBadges";
import { DocumentSelector } from "@/components/flow/DocumentSelector";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
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";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { CONTEXT_VARIABLES_DOC_URL, NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface AgentNodeEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
allowInterrupt: boolean;
setAllowInterrupt: (value: boolean) => void;
extractionEnabled: boolean;
setExtractionEnabled: (value: boolean) => void;
extractionPrompt: string;
setExtractionPrompt: (value: string) => void;
variables: ExtractionVariable[];
setVariables: (vars: ExtractionVariable[]) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
toolUuids: string[];
setToolUuids: (value: string[]) => void;
documentUuids: string[];
setDocumentUuids: (value: string[]) => void;
tools: ToolResponse[];
documents: DocumentResponseSchema[];
recordings: RecordingResponseSchema[];
}
interface AgentNodeProps extends NodeProps {
data: FlowNodeData;
}
export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
// Variable Extraction state
const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false);
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 [documentUuids, setDocumentUuids] = useState<string[]>(data.document_uuids ?? []);
// Compute if form has unsaved changes (only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
name,
allow_interrupt: allowInterrupt,
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
});
setOpen(false);
await saveWorkflow();
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setToolUuids(data.tool_uuids ?? []);
setDocumentUuids(data.document_uuids ?? []);
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setToolUuids(data.tool_uuids ?? []);
setDocumentUuids(data.document_uuids ?? []);
}
}, [data, open]);
// Handle cleanup of stale document UUIDs
const handleStaleDocuments = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
// Handle cleanup of stale tool UUIDs
const handleStaleTools = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Agent'}
icon={<Headset />}
nodeType="agent"
hasSourceHandle={true}
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<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} onStaleUuidsDetected={handleStaleTools} />
</div>
)}
{data.document_uuids && data.document_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">
<FileText className="h-3 w-3" />
<span>Documents:</span>
</div>
<DocumentBadges documentUuids={data.document_uuids} onStaleUuidsDetected={handleStaleDocuments} />
</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 Agent"
onSave={handleSave}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.agent}
>
{open && (
<AgentNodeEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
allowInterrupt={allowInterrupt}
setAllowInterrupt={setAllowInterrupt}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
toolUuids={toolUuids}
setToolUuids={setToolUuids}
documentUuids={documentUuids}
setDocumentUuids={setDocumentUuids}
tools={tools ?? []}
documents={documents ?? []}
recordings={recordings ?? []}
/>
)}
</NodeEditDialog>
</>
);
});
const AgentNodeEditForm = ({
prompt,
setPrompt,
name,
setName,
allowInterrupt,
setAllowInterrupt,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
toolUuids,
setToolUuids,
documentUuids,
setDocumentUuids,
tools,
documents,
recordings,
}: AgentNodeEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], name: value };
setVariables(newVars);
};
const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], type: value };
setVariables(newVars);
};
const handleVariablePromptChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], prompt: value };
setVariables(newVars);
};
const handleRemoveVariable = (idx: number) => {
const newVars = variables.filter((_, i) => i !== idx);
setVariables(newVars);
};
const handleAddVariable = () => {
setVariables([...variables, { name: '', type: 'string', prompt: '' }]);
};
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-muted-foreground ml-2">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-muted-foreground ml-2">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
<div className="pt-2 space-y-2">
<Label>Prompt</Label>
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Supports <a href={CONTEXT_VARIABLES_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">template variables</a>
</Label>
<MentionTextarea
value={prompt}
onChange={setPrompt}
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
placeholder="Enter a prompt"
recordings={recordings}
/>
</div>
{/* Variable Extraction Section */}
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-muted-foreground ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-muted-foreground">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
value={extractionPrompt}
onChange={(e) => setExtractionPrompt(e.target.value)}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: 'auto' }}
/>
<Label>Variables</Label>
<Label className="text-xs text-muted-foreground">
Define each variable you want to extract along with its data type.
</Label>
{variables.map((v, idx) => (
<div key={idx} className="space-y-2 border rounded-md p-2 bg-background">
<div className="flex items-center gap-2">
<Input
placeholder="Variable name"
value={v.name}
onChange={(e) => handleVariableNameChange(idx, e.target.value)}
/>
<select
className="border rounded-md p-2 text-sm bg-background"
value={v.type}
onChange={(e) => handleVariableTypeChange(idx, e.target.value as 'string' | 'number' | 'boolean')}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
<Button variant="outline" size="icon" onClick={() => handleRemoveVariable(idx)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
<Textarea
placeholder="Extraction prompt for this variable"
value={v.prompt ?? ''}
onChange={(e) => handleVariablePromptChange(idx, e.target.value)}
className="min-h-[60px] resize-none"
/>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAddVariable}>
<PlusIcon className="w-4 h-4 mr-1" /> Add Variable
</Button>
</div>
)}
{/* Tools Section */}
<div className="pt-4 border-t mt-4">
<ToolSelector
value={toolUuids}
onChange={setToolUuids}
tools={tools}
description="Select tools that the agent can invoke during this conversation step."
/>
</div>
{/* Documents Section */}
<div className="pt-4 border-t mt-4">
<DocumentSelector
value={documentUuids}
onChange={setDocumentUuids}
documents={documents}
description="Select documents from the knowledge base that the agent can reference during this conversation step."
/>
</div>
</div>
);
};
AgentNode.displayName = "AgentNode";

View file

@ -1,302 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
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";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { CONTEXT_VARIABLES_DOC_URL, NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface EndCallEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
extractionEnabled: boolean;
setExtractionEnabled: (value: boolean) => void;
extractionPrompt: string;
setExtractionPrompt: (value: string) => void;
variables: ExtractionVariable[];
setVariables: (vars: ExtractionVariable[]) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
recordings: RecordingResponseSchema[];
}
interface EndCallNodeProps extends NodeProps {
data: FlowNodeData;
}
export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({
id,
additionalData: { is_end: true }
});
const { saveWorkflow, recordings } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
// Variable Extraction state
const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false);
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
// Compute if form has unsaved changes (simplified: only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
name,
allow_interrupt: false, // Always set to false for end nodes
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
});
setOpen(false);
await saveWorkflow();
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setName(data.name);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title="End Call"
icon={<OctagonX />}
nodeType="end"
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</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="End Call"
onSave={handleSave}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.endCall}
>
{open && (
<EndCallEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
recordings={recordings ?? []}
/>
)}
</NodeEditDialog>
</>
);
});
const EndCallEditForm = ({
prompt,
setPrompt,
name,
setName,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
recordings,
}: EndCallEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], name: value };
setVariables(newVars);
};
const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], type: value };
setVariables(newVars);
};
const handleVariablePromptChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], prompt: value };
setVariables(newVars);
};
const handleRemoveVariable = (idx: number) => {
const newVars = variables.filter((_, i) => i !== idx);
setVariables(newVars);
};
const handleAddVariable = () => {
setVariables([...variables, { name: '', type: 'string', prompt: '' }]);
};
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Label>Prompt</Label>
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Supports <a href={CONTEXT_VARIABLES_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">template variables</a>
</Label>
<MentionTextarea
value={prompt}
onChange={setPrompt}
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
placeholder="Enter a prompt"
recordings={recordings}
/>
<div className="flex items-center space-x-2">
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
<Label className="text-xs text-muted-foreground">
Whether you want to add global prompt with this node&apos;s prompt.
</Label>
</div>
{/* Variable Extraction Section */}
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-muted-foreground ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-muted-foreground">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
value={extractionPrompt}
onChange={(e) => setExtractionPrompt(e.target.value)}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: 'auto' }}
/>
<Label>Variables</Label>
<Label className="text-xs text-muted-foreground">
Define each variable you want to extract along with its data type.
</Label>
{variables.map((v, idx) => (
<div key={idx} className="space-y-2 border rounded-md p-2 bg-background">
<div className="flex items-center gap-2">
<Input
placeholder="Variable name"
value={v.name}
onChange={(e) => handleVariableNameChange(idx, e.target.value)}
/>
<select
className="border rounded-md p-2 text-sm bg-background"
value={v.type}
onChange={(e) => handleVariableTypeChange(idx, e.target.value as 'string' | 'number' | 'boolean')}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
<Button variant="outline" size="icon" onClick={() => handleRemoveVariable(idx)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
<Textarea
placeholder="Extraction prompt for this variable"
value={v.prompt ?? ''}
onChange={(e) => handleVariablePromptChange(idx, e.target.value)}
className="min-h-[60px] resize-none"
/>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAddVariable}>
<PlusIcon className="w-4 h-4 mr-1" /> Add Variable
</Button>
</div>
)}
</div>
);
};
EndCall.displayName = "EndCall";

View file

@ -0,0 +1,485 @@
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 { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
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<string, NodeStyleVariant> = {
startCall: "start",
agentNode: "agent",
endCall: "end",
globalNode: "global",
trigger: "trigger",
webhook: "webhook",
qa: "qa",
};
const HANDLES_BY_SPEC: Record<string, { source: boolean; target: boolean }> = {
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<string, string | undefined> = {
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<string, LucideIcon>;
return icons[name] ?? Circle;
}
function seedValues(
data: FlowNodeData,
spec: NodeSpec,
): Record<string, unknown> {
const d = data as unknown as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const prop of spec.properties) {
out[prop.name] = d[prop.name] ?? prop.default ?? undefined;
}
return out;
}
function buildTriggerEndpoint(triggerPath: string | undefined): string {
if (!triggerPath) return "";
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
return `${backendUrl}/api/v1/public/agent/${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 = buildTriggerEndpoint(data.trigger_path);
return (
<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 || "Generating..."}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
onCopyTrigger();
}}
>
{triggerCopied ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
);
}
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 (
<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">
{method}
</span>
<span className="text-xs text-muted-foreground truncate flex-1">
{truncated}
</span>
</div>
<StatusDot enabled={enabled} />
</div>
);
}
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 (
<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">
{llmSource}
</span>
</div>
<StatusDot enabled={enabled} />
</div>
);
}
// 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 (
<>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || "No prompt configured"}
</p>
{hasToolRefs && 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">
<LucideIcons.Wrench className="h-3 w-3" />
<span>Tools:</span>
</div>
<ToolBadges
toolUuids={data.tool_uuids}
onStaleUuidsDetected={onStaleTools}
/>
</div>
)}
{hasDocRefs && data.document_uuids && data.document_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">
<LucideIcons.FileText className="h-3 w-3" />
<span>Documents:</span>
</div>
<DocumentBadges
documentUuids={data.document_uuids}
onStaleUuidsDetected={onStaleDocuments}
/>
</div>
)}
</>
);
}
function StatusDot({ enabled }: { enabled: boolean }) {
return (
<div className="flex items-center gap-1.5">
<Circle
className={`h-2 w-2 ${
enabled
? "fill-green-500 text-green-500"
: "fill-gray-400 text-gray-400"
}`}
/>
<span className="text-xs text-muted-foreground">
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
}
// ─── Trigger curl example helper (rendered inside the dialog form) ────────
function TriggerCurlExample({ endpoint }: { endpoint: string }) {
const [copied, setCopied] = useState(false);
const curl = `curl -X POST "${endpoint}" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone_number": "+1234567890", "initial_context": {}}'`;
return (
<div className="grid gap-2">
<p className="text-sm font-medium">API Endpoint</p>
<p 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>
</p>
<code className="text-xs break-all bg-muted px-2 py-1 rounded">
{endpoint || "Generating..."}
</code>
<p className="text-sm font-medium pt-2">Example Request</p>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded overflow-x-auto whitespace-pre-wrap">
{curl}
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2"
onClick={async () => {
await navigator.clipboard.writeText(curl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
// ─── 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<Record<string, boolean> | undefined>(() => {
const out: Record<string, boolean> = {};
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<Record<string, unknown>>(() =>
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 = buildTriggerEndpoint(data.trigger_path);
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(() => {
const d = data as unknown as Record<string, unknown>;
return propertyNames.some((n) => values[n] !== d[n]);
}, [values, data, propertyNames]);
const handleSave = async () => {
if (!spec) return;
handleSaveNodeData({
...data,
...(values as Partial<FlowNodeData>),
});
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 (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || fallbackTitle}
icon={<Icon />}
nodeType={styleVariant}
hasSourceHandle={handles.source}
hasTargetHandle={handles.target}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
{spec && (
<CanvasPreview
spec={spec}
data={data}
onCopyTrigger={handleCopyTrigger}
triggerCopied={triggerCopied}
onStaleTools={handleStaleTools}
onStaleDocuments={handleStaleDocuments}
/>
)}
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<div className="flex flex-col gap-1">
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
{/* Start nodes can't be deleted (workflow always needs one). */}
{type !== "startCall" && (
<Button
onClick={handleDeleteNode}
variant="outline"
size="icon"
>
<Trash2Icon />
</Button>
)}
</div>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title={dialogTitle}
onSave={handleSave}
isDirty={isDirty}
documentationUrl={docUrl}
>
{open && spec && (
<div className="grid gap-4">
<NodeEditForm
spec={spec}
values={values}
onChange={setValues}
context={{
tools: tools ?? [],
documents: documents ?? [],
recordings: recordings ?? [],
}}
/>
{type === "trigger" && (
<TriggerCurlExample
endpoint={buildTriggerEndpoint(data.trigger_path)}
/>
)}
</div>
)}
</NodeEditDialog>
</>
);
});
GenericNode.displayName = "GenericNode";

View file

@ -1,162 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, Trash2Icon } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
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 { CONTEXT_VARIABLES_DOC_URL, NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface GlobalNodeEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
recordings: RecordingResponseSchema[];
}
interface GlobalNodeProps extends NodeProps {
data: FlowNodeData;
}
export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow, recordings } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
// Compute if form has unsaved changes (simplified: only check prompt, name)
const isDirty = useMemo(() => {
return (
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [prompt, name, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: false,
name
});
setOpen(false);
await saveWorkflow();
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setName(data.name);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Global'}
icon={<Headset />}
nodeType="global"
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
{data.prompt || 'No prompt configured'}
</p>
</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 Global Node"
onSave={handleSave}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.global}
>
{open && (
<GlobalNodeEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
recordings={recordings ?? []}
/>
)}
</NodeEditDialog>
</>
);
});
const GlobalNodeEditForm = ({
prompt,
setPrompt,
name,
setName,
recordings,
}: GlobalNodeEditFormProps) => {
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
The name of the global node.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Label>Prompt</Label>
<Label className="text-xs text-muted-foreground">
This is the global prompt. This will be added to the system prompt of all the agents. Supports <a href={CONTEXT_VARIABLES_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">template variables</a>
</Label>
<MentionTextarea
value={prompt}
onChange={setPrompt}
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
placeholder="Enter a prompt"
recordings={recordings}
/>
</div>
);
};
GlobalNode.displayName = "GlobalNode";

View file

@ -1,350 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { ChevronDown, ChevronRight, Circle, ClipboardCheck, Edit, Trash2Icon } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface QANodeProps extends NodeProps {
data: FlowNodeData;
}
export const QANode = memo(({ data, selected, id }: QANodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [name, setName] = useState(data.name || "QA Analysis");
const [qaEnabled, setQaEnabled] = useState(data.qa_enabled ?? true);
const [useWorkflowLlm, setUseWorkflowLlm] = useState(data.qa_use_workflow_llm ?? true);
const [qaProvider, setQaProvider] = useState(data.qa_provider || "openai");
const [qaModel, setQaModel] = useState(data.qa_model || "gpt-4.1");
const [qaApiKey, setQaApiKey] = useState(data.qa_api_key || "");
const [qaSystemPrompt, setQaSystemPrompt] = useState(data.qa_system_prompt || "");
const [minCallDuration, setMinCallDuration] = useState(data.qa_min_call_duration ?? 15);
const [qaVoicemailCalls, setQaVoicemailCalls] = useState(data.qa_voicemail_calls ?? false);
const [qaSampleRate, setQaSampleRate] = useState(data.qa_sample_rate ?? 100);
const isDirty = useMemo(() => {
return (
name !== (data.name || "QA Analysis") ||
qaEnabled !== (data.qa_enabled ?? true) ||
useWorkflowLlm !== (data.qa_use_workflow_llm ?? true) ||
qaProvider !== (data.qa_provider || "openai") ||
qaModel !== (data.qa_model || "gpt-4.1") ||
qaApiKey !== (data.qa_api_key || "") ||
qaSystemPrompt !== (data.qa_system_prompt || "") ||
minCallDuration !== (data.qa_min_call_duration ?? 15) ||
qaVoicemailCalls !== (data.qa_voicemail_calls ?? false) ||
qaSampleRate !== (data.qa_sample_rate ?? 100)
);
}, [name, qaEnabled, useWorkflowLlm, qaProvider, qaModel, qaApiKey, qaSystemPrompt, minCallDuration, qaVoicemailCalls, qaSampleRate, data]);
const handleSave = async () => {
handleSaveNodeData({
...data,
name,
qa_enabled: qaEnabled,
qa_use_workflow_llm: useWorkflowLlm,
qa_provider: qaProvider,
qa_model: qaModel,
qa_api_key: qaApiKey,
qa_system_prompt: qaSystemPrompt,
qa_min_call_duration: minCallDuration,
qa_voicemail_calls: qaVoicemailCalls,
qa_sample_rate: qaSampleRate,
});
setOpen(false);
await saveWorkflow();
};
const resetFormState = () => {
setName(data.name || "QA Analysis");
setQaEnabled(data.qa_enabled ?? true);
setUseWorkflowLlm(data.qa_use_workflow_llm ?? true);
setQaProvider(data.qa_provider || "openai");
setQaModel(data.qa_model || "gpt-4.1");
setQaApiKey(data.qa_api_key || "");
setQaSystemPrompt(data.qa_system_prompt || "");
setMinCallDuration(data.qa_min_call_duration ?? 15);
setQaVoicemailCalls(data.qa_voicemail_calls ?? false);
setQaSampleRate(data.qa_sample_rate ?? 100);
};
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
resetFormState();
}
setOpen(newOpen);
};
useEffect(() => {
if (open) {
resetFormState();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || "QA Analysis"}
icon={<ClipboardCheck />}
nodeType="qa"
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.qa_use_workflow_llm !== false
? "Workflow LLM"
: `${data.qa_provider || "openai"}/${data.qa_model || "gpt-4.1"}`}
</span>
</div>
<div className="flex items-center gap-1.5">
<Circle
className={`h-2 w-2 ${data.qa_enabled !== false ? "fill-green-500 text-green-500" : "fill-gray-400 text-gray-400"}`}
/>
<span className="text-xs text-muted-foreground">
{data.qa_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 QA Analysis"
onSave={handleSave}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.qaAnalysis}
>
{open && (
<QANodeEditForm
name={name}
setName={setName}
qaEnabled={qaEnabled}
setQaEnabled={setQaEnabled}
useWorkflowLlm={useWorkflowLlm}
setUseWorkflowLlm={setUseWorkflowLlm}
qaProvider={qaProvider}
setQaProvider={setQaProvider}
qaModel={qaModel}
setQaModel={setQaModel}
qaApiKey={qaApiKey}
setQaApiKey={setQaApiKey}
qaSystemPrompt={qaSystemPrompt}
setQaSystemPrompt={setQaSystemPrompt}
minCallDuration={minCallDuration}
setMinCallDuration={setMinCallDuration}
qaVoicemailCalls={qaVoicemailCalls}
setQaVoicemailCalls={setQaVoicemailCalls}
qaSampleRate={qaSampleRate}
setQaSampleRate={setQaSampleRate}
/>
)}
</NodeEditDialog>
</>
);
});
interface QANodeEditFormProps {
name: string;
setName: (value: string) => void;
qaEnabled: boolean;
setQaEnabled: (value: boolean) => void;
useWorkflowLlm: boolean;
setUseWorkflowLlm: (value: boolean) => void;
qaProvider: string;
setQaProvider: (value: string) => void;
qaModel: string;
setQaModel: (value: string) => void;
qaApiKey: string;
setQaApiKey: (value: string) => void;
qaSystemPrompt: string;
setQaSystemPrompt: (value: string) => void;
minCallDuration: number;
setMinCallDuration: (value: number) => void;
qaVoicemailCalls: boolean;
setQaVoicemailCalls: (value: boolean) => void;
qaSampleRate: number;
setQaSampleRate: (value: number) => void;
}
const QANodeEditForm = ({
name,
setName,
qaEnabled,
setQaEnabled,
useWorkflowLlm,
setUseWorkflowLlm,
qaProvider,
setQaProvider,
qaModel,
setQaModel,
qaApiKey,
setQaApiKey,
qaSystemPrompt,
setQaSystemPrompt,
minCallDuration,
setMinCallDuration,
qaVoicemailCalls,
setQaVoicemailCalls,
qaSampleRate,
setQaSampleRate,
}: QANodeEditFormProps) => {
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
<div className="space-y-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this QA analysis node.
</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="qa-enabled" checked={qaEnabled} onCheckedChange={setQaEnabled} />
<Label htmlFor="qa-enabled">Enabled</Label>
<Label className="text-xs text-muted-foreground ml-2">
Whether this QA analysis runs after each call.
</Label>
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch
id="use-workflow-llm"
checked={useWorkflowLlm}
onCheckedChange={setUseWorkflowLlm}
/>
<Label htmlFor="use-workflow-llm">Use Workflow LLM</Label>
<Label className="text-xs text-muted-foreground ml-2">
Use the LLM configured in your account settings.
</Label>
</div>
{!useWorkflowLlm && (
<LLMConfigSelector
provider={qaProvider}
onProviderChange={setQaProvider}
model={qaModel}
onModelChange={setQaModel}
apiKey={qaApiKey}
onApiKeyChange={setQaApiKey}
/>
)}
<div className="grid gap-2">
<Label>System Prompt</Label>
<Label className="text-xs text-muted-foreground">
The prompt sent to the LLM for per-node QA analysis. Available placeholders:{' '}
{'{{node_summary}}'} (purpose of the current node), {'{{previous_conversation_summary}}'}{' '}
(summary of conversation before this node), {'{{transcript}}'} (this node&apos;s
conversation), {'{{metrics}}'} (call metrics for this node).
</Label>
<Textarea
value={qaSystemPrompt}
onChange={(e) => setQaSystemPrompt(e.target.value)}
className="min-h-[300px] font-mono text-xs"
placeholder={`You are a QA analyst evaluating a specific segment of a voice AI conversation.\n\n## Node Purpose\n{{node_summary}}\n\n## Previous Conversation Context\n{{previous_conversation_summary}}\n\n## Call Metrics\n{{metrics}}\n\nEvaluate the transcript and return JSON with:\n- "tags": array of relevant tags\n- "summary": 2-3 sentence summary of this segment\n- "call_quality_score": number 1-10\n- "overall_sentiment": "positive", "neutral", or "negative"`}
/>
</div>
{/* Advanced Configuration */}
<div className="border rounded-md">
<button
type="button"
className="flex items-center gap-2 w-full p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
{advancedOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
Advanced Configuration
</button>
{advancedOpen && (
<div className="px-3 pb-3 space-y-4 border-t pt-3">
<div className="grid gap-2">
<Label>Minimum Call Duration (seconds)</Label>
<Label className="text-xs text-muted-foreground">
Calls shorter than this duration will be skipped from QA analysis.
</Label>
<Input
type="number"
min={0}
value={minCallDuration}
onChange={(e) => setMinCallDuration(Number(e.target.value))}
/>
</div>
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
<Switch
id="qa-voicemail"
checked={qaVoicemailCalls}
onCheckedChange={setQaVoicemailCalls}
/>
<Label htmlFor="qa-voicemail">QA Voicemail Calls</Label>
<Label className="text-xs text-muted-foreground ml-2">
Run QA analysis on calls that reached voicemail.
</Label>
</div>
<div className="grid gap-2">
<Label>Sample Rate (%)</Label>
<Label className="text-xs text-muted-foreground">
Percentage of eligible calls to run QA analysis on.
</Label>
<Input
type="number"
min={1}
max={100}
value={qaSampleRate}
onChange={(e) =>
setQaSampleRate(
Math.min(100, Math.max(1, Number(e.target.value)))
)
}
/>
</div>
</div>
)}
</div>
</div>
);
};
QANode.displayName = "QANode";

View file

@ -1,601 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { ChevronRight, Edit, FileText, Play, PlusIcon, Settings, Trash2Icon, Wrench } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import type { DocumentResponseSchema, ToolResponse } from "@/client/types.gen";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { DocumentBadges } from "@/components/flow/DocumentBadges";
import { DocumentSelector } from "@/components/flow/DocumentSelector";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { ToolBadges } from "@/components/flow/ToolBadges";
import { ToolSelector } from "@/components/flow/ToolSelector";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
import { CredentialSelector, UrlInput, validateUrl } from "@/components/http";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { CONTEXT_VARIABLES_DOC_URL, NODE_DOCUMENTATION_URLS, PRE_CALL_DATA_FETCH_DOC_URL } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface StartCallEditFormProps {
nodeData: FlowNodeData;
greetingType: 'text' | 'audio';
setGreetingType: (value: 'text' | 'audio') => void;
greeting: string;
setGreeting: (value: string) => void;
greetingRecordingId: string;
setGreetingRecordingId: (value: string) => void;
prompt: string;
setPrompt: (value: string) => void;
name: string;
setName: (value: string) => void;
allowInterrupt: boolean;
setAllowInterrupt: (value: boolean) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
delayedStart: boolean;
setDelayedStart: (value: boolean) => void;
delayedStartDuration: number;
setDelayedStartDuration: (value: number) => void;
extractionEnabled: boolean;
setExtractionEnabled: (value: boolean) => void;
extractionPrompt: string;
setExtractionPrompt: (value: string) => void;
variables: ExtractionVariable[];
setVariables: (vars: ExtractionVariable[]) => void;
toolUuids: string[];
setToolUuids: (value: string[]) => void;
documentUuids: string[];
setDocumentUuids: (value: string[]) => void;
preCallFetchEnabled: boolean;
setPreCallFetchEnabled: (value: boolean) => void;
preCallFetchUrl: string;
setPreCallFetchUrl: (value: string) => void;
preCallFetchCredentialUuid: string;
setPreCallFetchCredentialUuid: (value: string) => void;
tools: ToolResponse[];
documents: DocumentResponseSchema[];
recordings: RecordingResponseSchema[];
}
interface StartCallNodeProps extends NodeProps {
data: FlowNodeData;
}
export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
const { open, setOpen, handleSaveNodeData } = useNodeHandlers({
id,
additionalData: { is_start: true }
});
const { saveWorkflow, tools, documents, recordings } = useWorkflow();
// Form state
const [greetingType, setGreetingType] = useState<'text' | 'audio'>(data.greeting_type ?? "text");
const [greeting, setGreeting] = useState(data.greeting ?? "");
const [greetingRecordingId, setGreetingRecordingId] = useState(data.greeting_recording_id ?? "");
const [prompt, setPrompt] = useState(data.prompt ?? "");
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false);
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false);
const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? "");
const [variables, setVariables] = useState<ExtractionVariable[]>(data.extraction_variables ?? []);
const [toolUuids, setToolUuids] = useState<string[]>(data.tool_uuids ?? []);
const [documentUuids, setDocumentUuids] = useState<string[]>(data.document_uuids ?? []);
const [preCallFetchEnabled, setPreCallFetchEnabled] = useState(data.pre_call_fetch_enabled ?? false);
const [preCallFetchUrl, setPreCallFetchUrl] = useState(data.pre_call_fetch_url ?? "");
const [preCallFetchCredentialUuid, setPreCallFetchCredentialUuid] = useState(data.pre_call_fetch_credential_uuid ?? "");
// Compute if form has unsaved changes (only check prompt, name, greeting)
const isDirty = useMemo(() => {
return (
greeting !== (data.greeting ?? "") ||
prompt !== (data.prompt ?? "") ||
name !== (data.name ?? "")
);
}, [greeting, prompt, name, data]);
const handleSave = async () => {
// Validate pre-call fetch URL if enabled
if (preCallFetchEnabled && preCallFetchUrl) {
const urlValidation = validateUrl(preCallFetchUrl);
if (!urlValidation.valid) {
return;
}
}
handleSaveNodeData({
...data,
greeting_type: greetingType,
greeting: greetingType === 'text' ? (greeting || undefined) : undefined,
greeting_recording_id: greetingType === 'audio' ? (greetingRecordingId || undefined) : undefined,
prompt,
name,
allow_interrupt: allowInterrupt,
add_global_prompt: addGlobalPrompt,
delayed_start: delayedStart,
delayed_start_duration: delayedStart ? delayedStartDuration : undefined,
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
tool_uuids: toolUuids.length > 0 ? toolUuids : undefined,
document_uuids: documentUuids.length > 0 ? documentUuids : undefined,
pre_call_fetch_enabled: preCallFetchEnabled,
pre_call_fetch_url: preCallFetchEnabled ? preCallFetchUrl || undefined : undefined,
pre_call_fetch_credential_uuid: preCallFetchEnabled && preCallFetchCredentialUuid ? preCallFetchCredentialUuid : undefined,
});
setOpen(false);
await saveWorkflow();
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setGreetingType(data.greeting_type ?? "text");
setGreeting(data.greeting ?? "");
setGreetingRecordingId(data.greeting_recording_id ?? "");
setPrompt(data.prompt ?? "");
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setToolUuids(data.tool_uuids ?? []);
setDocumentUuids(data.document_uuids ?? []);
setPreCallFetchEnabled(data.pre_call_fetch_enabled ?? false);
setPreCallFetchUrl(data.pre_call_fetch_url ?? "");
setPreCallFetchCredentialUuid(data.pre_call_fetch_credential_uuid ?? "");
}
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setGreetingType(data.greeting_type ?? "text");
setGreeting(data.greeting ?? "");
setGreetingRecordingId(data.greeting_recording_id ?? "");
setPrompt(data.prompt ?? "");
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setToolUuids(data.tool_uuids ?? []);
setDocumentUuids(data.document_uuids ?? []);
setPreCallFetchEnabled(data.pre_call_fetch_enabled ?? false);
setPreCallFetchUrl(data.pre_call_fetch_url ?? "");
setPreCallFetchCredentialUuid(data.pre_call_fetch_credential_uuid ?? "");
}
}, [data, open]);
// Handle cleanup of stale document UUIDs
const handleStaleDocuments = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.document_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
document_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
// Handle cleanup of stale tool UUIDs
const handleStaleTools = useCallback(async (staleUuids: string[]) => {
const cleanedUuids = (data.tool_uuids ?? []).filter(uuid => !staleUuids.includes(uuid));
handleSaveNodeData({
...data,
tool_uuids: cleanedUuids.length > 0 ? cleanedUuids : undefined,
});
await saveWorkflow();
}, [data, handleSaveNodeData, saveWorkflow]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title="Start Call"
icon={<Play />}
nodeType="start"
hasSourceHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<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} onStaleUuidsDetected={handleStaleTools} />
</div>
)}
{data.document_uuids && data.document_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">
<FileText className="h-3 w-3" />
<span>Documents:</span>
</div>
<DocumentBadges documentUuids={data.document_uuids} onStaleUuidsDetected={handleStaleDocuments} />
</div>
)}
</NodeContent>
<NodeToolbar isVisible={selected} position={Position.Right}>
<Button onClick={() => setOpen(true)} variant="outline" size="icon">
<Edit />
</Button>
</NodeToolbar>
<NodeEditDialog
open={open}
onOpenChange={handleOpenChange}
nodeData={data}
title="Start Call"
onSave={handleSave}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.startCall}
>
{open && (
<StartCallEditForm
nodeData={data}
greetingType={greetingType}
setGreetingType={setGreetingType}
greeting={greeting}
setGreeting={setGreeting}
greetingRecordingId={greetingRecordingId}
setGreetingRecordingId={setGreetingRecordingId}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
allowInterrupt={allowInterrupt}
setAllowInterrupt={setAllowInterrupt}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
delayedStart={delayedStart}
setDelayedStart={setDelayedStart}
delayedStartDuration={delayedStartDuration}
setDelayedStartDuration={setDelayedStartDuration}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
toolUuids={toolUuids}
setToolUuids={setToolUuids}
documentUuids={documentUuids}
setDocumentUuids={setDocumentUuids}
preCallFetchEnabled={preCallFetchEnabled}
setPreCallFetchEnabled={setPreCallFetchEnabled}
preCallFetchUrl={preCallFetchUrl}
setPreCallFetchUrl={setPreCallFetchUrl}
preCallFetchCredentialUuid={preCallFetchCredentialUuid}
setPreCallFetchCredentialUuid={setPreCallFetchCredentialUuid}
tools={tools ?? []}
documents={documents ?? []}
recordings={recordings ?? []}
/>
)}
</NodeEditDialog>
</>
);
});
const StartCallEditForm = ({
greetingType,
setGreetingType,
greeting,
setGreeting,
greetingRecordingId,
setGreetingRecordingId,
prompt,
setPrompt,
name,
setName,
allowInterrupt,
setAllowInterrupt,
addGlobalPrompt,
setAddGlobalPrompt,
delayedStart,
setDelayedStart,
delayedStartDuration,
setDelayedStartDuration,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
toolUuids,
setToolUuids,
documentUuids,
setDocumentUuids,
preCallFetchEnabled,
setPreCallFetchEnabled,
preCallFetchUrl,
setPreCallFetchUrl,
preCallFetchCredentialUuid,
setPreCallFetchCredentialUuid,
tools,
documents,
recordings,
}: StartCallEditFormProps) => {
const handleVariableNameChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], name: value };
setVariables(newVars);
};
const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], type: value };
setVariables(newVars);
};
const handleVariablePromptChange = (idx: number, value: string) => {
const newVars = [...variables];
newVars[idx] = { ...newVars[idx], prompt: value };
setVariables(newVars);
};
const handleRemoveVariable = (idx: number) => {
const newVars = variables.filter((_, i) => i !== idx);
setVariables(newVars);
};
const handleAddVariable = () => {
setVariables([...variables, { name: '', type: 'string', prompt: '' }]);
};
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Label>Greeting</Label>
<Label className="text-xs text-muted-foreground">
Optional greeting played when the call starts. Choose between a text message (spoken via TTS) or a pre-recorded audio file.
</Label>
<TextOrAudioInput
type={greetingType}
onTypeChange={setGreetingType}
recordingId={greetingRecordingId}
onRecordingIdChange={setGreetingRecordingId}
recordings={recordings}
>
<Textarea
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
className="min-h-[60px] max-h-[200px] resize-none overflow-y-auto"
placeholder="e.g. Hello {{first_name}}, this is Sarah calling from Acme Corp."
/>
</TextOrAudioInput>
<Label>Prompt</Label>
<Label className="text-xs text-muted-foreground">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Supports <a href={CONTEXT_VARIABLES_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">template variables</a>
</Label>
<MentionTextarea
value={prompt}
onChange={setPrompt}
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
placeholder="Enter a prompt"
recordings={recordings}
/>
<div className="flex items-center space-x-2">
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
<Label className="text-xs text-muted-foreground">
Whether you would like user to be able to interrupt the bot.
</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="add-global-prompt"
checked={addGlobalPrompt}
onCheckedChange={setAddGlobalPrompt}
/>
<Label htmlFor="add-global-prompt">
Add Global Prompt
</Label>
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="delayed-start"
checked={delayedStart}
onCheckedChange={setDelayedStart}
/>
<Label htmlFor="delayed-start">
Delayed Start
</Label>
<Label className="text-xs text-muted-foreground">
Introduce a delay before the agent starts speaking.
</Label>
</div>
{delayedStart && (
<div className="ml-6 flex items-center space-x-2">
<Label htmlFor="delay-duration" className="text-sm">
Delay (seconds):
</Label>
<Input
id="delay-duration"
type="number"
step="0.1"
min="0.1"
max="10"
value={delayedStartDuration}
onChange={(e) => setDelayedStartDuration(parseFloat(e.target.value) || 3)}
className="w-20"
/>
</div>
)}
</div>
{/* Variable Extraction Section */}
<div className="flex items-center space-x-2 pt-2">
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
<Label className="text-xs text-muted-foreground ml-2">
Are there any variables you would like to extract from the conversation?
</Label>
</div>
{extractionEnabled && (
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
<Label>Extraction Prompt</Label>
<Label className="text-xs text-muted-foreground">
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
</Label>
<Textarea
value={extractionPrompt}
onChange={(e) => setExtractionPrompt(e.target.value)}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: 'auto' }}
/>
<Label>Variables</Label>
<Label className="text-xs text-muted-foreground">
Define each variable you want to extract along with its data type.
</Label>
{variables.map((v, idx) => (
<div key={idx} className="space-y-2 border rounded-md p-2 bg-background">
<div className="flex items-center gap-2">
<Input
placeholder="Variable name"
value={v.name}
onChange={(e) => handleVariableNameChange(idx, e.target.value)}
/>
<select
className="border rounded-md p-2 text-sm bg-background"
value={v.type}
onChange={(e) => handleVariableTypeChange(idx, e.target.value as 'string' | 'number' | 'boolean')}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
<Button variant="outline" size="icon" onClick={() => handleRemoveVariable(idx)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
<Textarea
placeholder="Extraction prompt for this variable"
value={v.prompt ?? ''}
onChange={(e) => handleVariablePromptChange(idx, e.target.value)}
className="min-h-[60px] resize-none"
/>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAddVariable}>
<PlusIcon className="w-4 h-4 mr-1" /> Add Variable
</Button>
</div>
)}
{/* Tools Section */}
<div className="pt-4 border-t mt-4">
<ToolSelector
value={toolUuids}
onChange={setToolUuids}
tools={tools}
description="Select tools that the agent can invoke during this conversation step."
/>
</div>
{/* Documents Section */}
<div className="pt-4 border-t mt-4">
<DocumentSelector
value={documentUuids}
onChange={setDocumentUuids}
documents={documents}
description="Select documents from the knowledge base that the agent can reference during this conversation step."
/>
</div>
{/* Advanced Settings */}
<div className="pt-4 border-t mt-4">
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 w-full text-sm font-medium hover:text-foreground text-muted-foreground">
<Settings className="h-4 w-4" />
<span>Advanced Settings</span>
<ChevronRight className="h-4 w-4 ml-auto transition-transform [[data-state=open]>svg&]:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent className="mt-4 space-y-4">
{/* Pre-Call Data Fetch */}
<div className="flex items-center space-x-2">
<Switch
id="pre-call-fetch"
checked={preCallFetchEnabled}
onCheckedChange={setPreCallFetchEnabled}
/>
<Label htmlFor="pre-call-fetch">Pre-Call Data Fetch</Label>
</div>
<p className="text-xs text-muted-foreground">
Fetch data from an external API before the call starts. A standardized POST request with caller/called numbers will be sent. The JSON response fields will be merged into the call context and available as template variables in your prompts.{" "}
<a href={PRE_CALL_DATA_FETCH_DOC_URL} target="_blank" rel="noopener noreferrer" className="underline">Learn more</a>
</p>
{preCallFetchEnabled && (
<div className="border rounded-md p-4 space-y-4 bg-muted/20">
<div className="grid gap-2">
<Label>Endpoint URL</Label>
<Label className="text-xs text-muted-foreground">
The URL to send the pre-call data fetch request to.
</Label>
<UrlInput
value={preCallFetchUrl}
onChange={setPreCallFetchUrl}
placeholder="https://api.example.com/customer-lookup"
showValidation
/>
</div>
<div className="grid gap-2">
<Label>Authentication</Label>
<CredentialSelector
value={preCallFetchCredentialUuid}
onChange={setPreCallFetchCredentialUuid}
/>
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
</div>
</div>
);
};
StartCall.displayName = "StartCall";

View file

@ -1,240 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Check, Copy, Edit, Trash2Icon, Webhook } from "lucide-react";
import Link from "next/link";
import { memo, useEffect, useMemo, 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 { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
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());
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
const endpoint = `${backendUrl}/api/v1/public/agent/${triggerPath}`;
// Copy state for button feedback
const [copied, setCopied] = useState(false);
// Compute if form has unsaved changes (simplified: only check name)
const isDirty = useMemo(() => {
return name !== (data.name || "API Trigger");
}, [name, data.name]);
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);
await saveWorkflow();
};
// 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}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.apiTrigger}
>
{open && (
<TriggerNodeEditForm
name={name}
setName={setName}
endpoint={endpoint}
/>
)}
</NodeEditDialog>
</>
);
});
const TriggerNodeEditForm = ({
name,
setName,
endpoint,
}: TriggerNodeEditFormProps) => {
const [copied, setCopied] = useState(false);
const [curlCopied, setCurlCopied] = useState(false);
const handleCopyEndpoint = async () => {
await navigator.clipboard.writeText(endpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const curlExample = `curl -X POST "${endpoint}" \\
-H "X-API-Key: YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone_number": "+1234567890", "initial_context": {}}'`;
const handleCopyCurl = async () => {
await navigator.clipboard.writeText(curlExample);
setCurlCopied(true);
setTimeout(() => setCurlCopied(false), 2000);
};
return (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-muted-foreground">
A display name for this trigger.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>API Endpoint</Label>
<Label className="text-xs text-muted-foreground">
Use this endpoint to trigger calls via API. Requires an API key in the X-API-Key header.{" "}
<Link href="/api-keys" target="_blank" className="text-primary underline hover:no-underline">
Get your API key
</Link>
</Label>
<div className="flex items-center gap-2">
<code className="text-xs break-all bg-muted px-2 py-1 rounded flex-1">
{endpoint}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopyEndpoint}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>Example Request</Label>
<div className="relative">
<pre className="text-xs bg-muted px-3 py-2 rounded overflow-x-auto whitespace-pre-wrap">
{curlExample}
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-2 right-2"
onClick={handleCopyCurl}
>
{curlCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
);
};
TriggerNode.displayName = "TriggerNode";

View file

@ -1,350 +0,0 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Circle, Edit, Link2, Trash2Icon } from "lucide-react";
import { memo, useEffect, useMemo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
import {
CredentialSelector,
type HttpMethod,
HttpMethodSelector,
KeyValueEditor,
type KeyValueItem,
UrlInput,
validateUrl,
} 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 { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NODE_DOCUMENTATION_URLS } from "@/constants/documentation";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface WebhookNodeProps extends NodeProps {
data: FlowNodeData;
}
export const WebhookNode = memo(({ data, selected, id }: WebhookNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// 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<KeyValueItem[]>(
data.custom_headers || []
);
const [payloadTemplate, setPayloadTemplate] = useState(
data.payload_template ? JSON.stringify(data.payload_template, null, 2) : "{}"
);
// Validation state - only shown on save attempt
const [jsonError, setJsonError] = useState<string | null>(null);
const [endpointError, setEndpointError] = useState<string | null>(null);
// Compute if form has unsaved changes (simplified: only check name, endpoint)
const isDirty = useMemo(() => {
return (
name !== (data.name || "Webhook") ||
endpointUrl !== (data.endpoint_url || "")
);
}, [name, endpointUrl, data]);
const handleSave = async () => {
// Validate endpoint URL
const urlValidation = validateUrl(endpointUrl);
if (!urlValidation.valid) {
setEndpointError(urlValidation.error || 'Invalid URL');
return;
}
setEndpointError(null);
// Validate JSON payload
const validation = validateJson(payloadTemplate);
if (!validation.valid) {
setJsonError(validation.error || 'Invalid JSON. Please fix the payload template before saving.');
return;
}
setJsonError(null);
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: validation.parsed as Record<string, unknown>,
});
setOpen(false);
await saveWorkflow();
};
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) : "{}"
);
// Clear any previous errors
setJsonError(null);
setEndpointError(null);
}
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}
error={endpointError || jsonError}
isDirty={isDirty}
documentationUrl={NODE_DOCUMENTATION_URLS.webhook}
>
{open && (
<WebhookNodeEditForm
name={name}
setName={setName}
enabled={enabled}
setEnabled={setEnabled}
httpMethod={httpMethod}
setHttpMethod={setHttpMethod}
endpointUrl={endpointUrl}
setEndpointUrl={setEndpointUrl}
credentialUuid={credentialUuid}
setCredentialUuid={setCredentialUuid}
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;
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,
enabled,
setEnabled,
httpMethod,
setHttpMethod,
endpointUrl,
setEndpointUrl,
credentialUuid,
setCredentialUuid,
customHeaders,
setCustomHeaders,
payloadTemplate,
setPayloadTemplate,
}: WebhookNodeEditFormProps) => {
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>
<HttpMethodSelector
value={httpMethod}
onChange={setHttpMethod}
/>
</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>
<UrlInput
value={endpointUrl}
onChange={setEndpointUrl}
placeholder="https://api.example.com/webhook"
showValidation
/>
</div>
</TabsContent>
<TabsContent value="auth" className="space-y-4 mt-4">
<CredentialSelector
value={credentialUuid}
onChange={setCredentialUuid}
/>
</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>
<KeyValueEditor
items={customHeaders}
onChange={setCustomHeaders}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addButtonText="Add Header"
/>
</div>
</TabsContent>
<TabsContent value="payload" className="space-y-4 mt-4">
<JsonEditor
value={payloadTemplate}
onChange={setPayloadTemplate}
label="Payload Template (JSON)"
description='Define the JSON payload. Use "{{variable}}" syntax for dynamic values (must be quoted strings).'
placeholder='{"call_id": "{{workflow_run_id}}", "name": "{{initial_context.name}}"}'
minHeight="200px"
/>
<div className="border rounded-md p-3 bg-muted/20">
<Label className="text-sm font-medium">Available Variables</Label>
<div className="mt-2 space-y-1">
{availableVariables.map((v) => (
<div key={v.name} className="text-xs">
<code className="bg-muted px-1 py-0.5 rounded">
{`{{${v.name}}}`}
</code>
<span className="text-muted-foreground ml-2">{v.description}</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
};
WebhookNode.displayName = "WebhookNode";

View file

@ -1,7 +0,0 @@
export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './QANode';
export * from './StartCall';
export * from './TriggerNode';
export * from './WebhookNode';