Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

View file

@ -0,0 +1,105 @@
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
};
const NODE_TYPES = [
{
type: NodeType.START_CALL,
label: 'Start Call',
description: 'Create a start call node',
icon: Play
},
{
type: NodeType.AGENT_NODE,
label: 'Agent Node',
description: 'Create an agent node',
icon: Headset
},
{
type: NodeType.END_CALL,
label: 'End Call',
description: 'Create an end call node',
icon: OctagonX
}
];
const GLOBAL_NODE_TYPES = [
{
type: NodeType.GLOBAL_NODE,
label: 'Global Node',
description: 'Create a global node',
icon: Globe
}
]
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
return (
<div
className={`fixed z-51 right-0 top-0 h-full w-80 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Add New Node</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<h1 className="text-sm text-gray-500 mb-2">Agent Nodes</h1>
<div className="space-y-2">
{NODE_TYPES.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
<h1 className="text-sm text-gray-500 mb-2">Global Nodes</h1>
<div className="space-y-2">
{GLOBAL_NODE_TYPES.map((node) => (
<Button
variant="outline"
className="w-full justify-start p-4 h-auto"
key={node.type}
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
<node.icon className="h-6 w-6" />
</div>
<div className="flex flex-col items-start">
<span className="font-medium">{node.label}</span>
<span className="text-sm text-gray-500">{node.description}</span>
</div>
</div>
</Button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,188 @@
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import { AlertCircle, Pencil } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from '@/components/ui/textarea';
import { cn } from "@/lib/utils";
import { FlowEdge, FlowEdgeData, FlowNode } from '../types';
type CustomEdge = Edge<{ value: number }, 'custom'>;
interface EdgeDetailsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data?: FlowEdgeData;
onSave: (value: FlowEdgeData) => void;
}
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
const [condition, setCondition] = useState(data?.condition ?? '');
const [label, setLabel] = useState(data?.label ?? '');
const handleSave = () => {
onSave({ condition: condition, label: label });
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Condition</DialogTitle>
{data?.invalid && data.validationMessage && (
<div className="mt-2 flex items-center gap-2 rounded-md bg-red-50 p-2 text-sm text-red-500 border border-red-200">
<AlertCircle className="h-4 w-4" />
<span>{data.validationMessage}</span>
</div>
)}
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Condition Label</Label>
<Label className="text-xs text-gray-500">
Enter a short label which helps identify this pathway in logs
</Label>
<Input
type="text"
value={label}
maxLength={64}
onChange={(e) => setLabel(e.target.value)}
/>
<div className="text-xs text-gray-500">
{label.length}/64 characters
</div>
</div>
<div className="grid gap-2">
<Label>Condition</Label>
<Label className="text-xs text-gray-500">
Describe a condition that will be evaluated to determine if this pathway should be taken
</Label>
<Textarea
value={condition}
onChange={(e) => setCondition(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface CustomEdgeProps extends EdgeProps {
data: FlowEdgeData;
}
export default function CustomEdge(props: CustomEdgeProps) {
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props;
const { getEdges, setEdges } = useReactFlow<FlowNode, FlowEdge>();
const { saveWorkflow } = useWorkflow();
const parallel = getEdges().filter(
(e) =>
(e.source === source && e.target === target) ||
(e.source === target && e.target === source)
);
// 2) if there are two, sort by id and pick an index
let offsetX = 0;
let offsetY = 0;
if (parallel.length > 1) {
const sorted = parallel.slice().sort((a, b) => a.id.localeCompare(b.id));
const idx = sorted.findIndex((e) => e.id === id);
// first edge (idx 0) moves right & down;
// second edge (idx 1) moves left & up
if (idx === 0) {
offsetX = 100;
offsetY = 0;
} else {
offsetX = 0;
offsetY = -50;
}
}
// 3) draw the straight path + get label coords
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const [open, setOpen] = useState(false);
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
// Update the node data in the ReactFlow nodes state
setEdges((edges) => {
const updatedEdges = edges.map((edge) =>
edge.id === id
? { ...edge, data: updatedData }
: edge
)
return updatedEdges;
}
);
// Save the workflow after updating edge data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
}, [id, setEdges, saveWorkflow]);
return (
<>
<BaseEdge
id={id}
path={edgePath}
/>
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
pointerEvents: 'all',
transformOrigin: 'center',
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
}}
className="nodrag nopan"
>
<div className={cn(
"flex items-center gap-2 bg-white pl-3 pr-1 py-1 rounded-md border shadow-sm",
data?.invalid ? "border-red-500/30 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "border-gray-200"
)}>
<div className="flex flex-col">
<span className="text-sm">{data?.label || data?.condition || 'Set Condition'}</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={() => setOpen(true)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</div>
</EdgeLabelRenderer>
<EdgeDetailsDialog
open={open}
onOpenChange={setOpen}
data={data}
onSave={handleSaveEdgeData}
/>
</>
);
}

View file

@ -0,0 +1,298 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
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 { 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;
}
interface AgentNodeProps extends NodeProps {
data: FlowNodeData;
}
export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = 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 handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
name,
allow_interrupt: allowInterrupt,
extraction_enabled: extractionEnabled,
extraction_prompt: extractionPrompt,
extraction_variables: variables,
add_global_prompt: addGlobalPrompt,
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// 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);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title={data.name || 'Agent'}
icon={<Headset />}
bgColor="bg-blue-300"
hasSourceHandle={true}
hasTargetHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</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}
>
{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}
/>
)}
</NodeEditDialog>
</>
);
});
const AgentNodeEditForm = ({
prompt,
setPrompt,
name,
setName,
allowInterrupt,
setAllowInterrupt,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
}: 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-gray-500">
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-gray-500 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-gray-500 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-gray-500">
Enter the prompt for the agent. This will be used to generate the agent&apos;s response. Prompt engineering&apos;s best practices apply.
</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
/>
</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-gray-500 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-gray-500">
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-gray-500">
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>
);
};
AgentNode.displayName = "AgentNode";

View file

@ -0,0 +1,26 @@
import { Handle, HandleProps } from "@xyflow/react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
export type BaseHandleProps = HandleProps;
export const BaseHandle = forwardRef<HTMLDivElement, BaseHandleProps>(
({ className, children, ...props }, ref) => {
return (
<Handle
ref={ref}
{...props}
className={cn(
"h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition dark:border-secondary dark:bg-secondary",
className,
)}
{...props}
>
{children}
</Handle>
);
},
);
BaseHandle.displayName = "BaseHandle";

View file

@ -0,0 +1,26 @@
import { forwardRef, HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export const BaseNode = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {
selected?: boolean;
invalid?: boolean;
}
>(({ className, selected, invalid, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative rounded-md border bg-card p-5 text-card-foreground min-w-[300px] min-h-[100px]",
className,
selected ? "border-muted-foreground shadow-lg" : "",
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
"hover:ring-1",
)}
tabIndex={0}
{...props}
/>
));
BaseNode.displayName = "BaseNode";

View file

@ -0,0 +1,283 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
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 { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface EndCallEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
isStatic: boolean;
setIsStatic: (value: boolean) => 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;
}
interface EndCallNodeProps extends NodeProps {
data: FlowNodeData;
}
export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
const { open, setOpen, handleSaveNodeData } = useNodeHandlers({
id,
additionalData: { is_end: true }
});
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [isStatic, setIsStatic] = useState(data.is_static ?? true);
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);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: isStatic,
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);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setIsStatic(data.is_static ?? true);
setName(data.name);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title="End Call"
icon={<OctagonX />}
bgColor="bg-red-300"
hasTargetHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</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="End Call"
onSave={handleSave}
>
{open && (
<EndCallEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
isStatic={isStatic}
setIsStatic={setIsStatic}
name={name}
setName={setName}
extractionEnabled={extractionEnabled}
setExtractionEnabled={setExtractionEnabled}
extractionPrompt={extractionPrompt}
setExtractionPrompt={setExtractionPrompt}
variables={variables}
setVariables={setVariables}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
/>
)}
</NodeEditDialog>
</>
);
});
const EndCallEditForm = ({
prompt,
setPrompt,
isStatic,
setIsStatic,
name,
setName,
extractionEnabled,
setExtractionEnabled,
extractionPrompt,
setExtractionPrompt,
variables,
setVariables,
addGlobalPrompt,
setAddGlobalPrompt,
}: 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-gray-500">
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>{isStatic ? "Text" : "Prompt"}</Label>
<Label className="text-xs text-gray-500">
What would you like the agent to say when the call ends? Its a good idea to have a static goodbye message.
</Label>
<div className="flex items-center space-x-2">
<Switch id="static-text" checked={isStatic} onCheckedChange={setIsStatic} />
<Label htmlFor="static-text">Static Text</Label>
</div>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
placeholder={isStatic ? "Thank you for calling Dograh. Have a great day!" : "Enter a dynamic prompt"}
/>
<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-gray-500">
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-gray-500 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-gray-500">
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-gray-500">
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,139 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, Trash2Icon } from "lucide-react";
import { memo, 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 { Textarea } from "@/components/ui/textarea";
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;
}
interface GlobalNodeProps extends NodeProps {
data: FlowNodeData;
}
export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
const { open, setOpen, handleSaveNodeData, handleDeleteNode } = useNodeHandlers({ id });
const { saveWorkflow } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt);
const [name, setName] = useState(data.name);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: false,
name
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt);
setName(data.name);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title={data.name || 'Global'}
icon={<Headset />}
bgColor="bg-orange-300"
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</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 Global Node"
onSave={handleSave}
>
{open && (
<GlobalNodeEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
name={name}
setName={setName}
/>
)}
</NodeEditDialog>
</>
);
});
const GlobalNodeEditForm = ({
prompt,
setPrompt,
name,
setName
}: GlobalNodeEditFormProps) => {
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
The name of the global node.
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Label>Prompt</Label>
<Label className="text-xs text-gray-500">
This is the global prompt. This will be added to the system prompt of all the agents.
</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
/>
</div>
);
};
GlobalNode.displayName = "GlobalNode";

View file

@ -0,0 +1,193 @@
import { Slot } from "@radix-ui/react-slot";
import { useNodeId, useReactFlow } from "@xyflow/react";
import { EllipsisVertical, Trash } from "lucide-react";
import { forwardRef, HTMLAttributes, ReactNode,useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
/* NODE HEADER -------------------------------------------------------------- */
export type NodeHeaderProps = HTMLAttributes<HTMLElement>;
/**
* A container for a consistent header layout intended to be used inside the
* `<BaseNode />` component.
*/
export const NodeHeader = forwardRef<HTMLElement, NodeHeaderProps>(
({ className, ...props }, ref) => {
return (
<header
ref={ref}
{...props}
className={cn(
"flex items-center justify-between gap-2 px-3 py-2",
// Remove or modify these classes if you modify the padding in the
// `<BaseNode />` component.
className,
)}
/>
);
},
);
NodeHeader.displayName = "NodeHeader";
/* NODE HEADER TITLE -------------------------------------------------------- */
export type NodeHeaderTitleProps = HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
};
/**
* The title text for the node. To maintain a native application feel, the title
* text is not selectable.
*/
export const NodeHeaderTitle = forwardRef<
HTMLHeadingElement,
NodeHeaderTitleProps
>(({ className, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "h3";
return (
<Comp
ref={ref}
{...props}
className={cn(className, "user-select-none flex-1 font-semibold")}
/>
);
});
NodeHeaderTitle.displayName = "NodeHeaderTitle";
/* NODE HEADER ICON --------------------------------------------------------- */
export type NodeHeaderIconProps = HTMLAttributes<HTMLSpanElement>;
export const NodeHeaderIcon = forwardRef<HTMLSpanElement, NodeHeaderIconProps>(
({ className, ...props }, ref) => {
return (
<span ref={ref} {...props} className={cn(className, "[&>*]:size-5")} />
);
},
);
NodeHeaderIcon.displayName = "NodeHeaderIcon";
/* NODE HEADER ACTIONS ------------------------------------------------------ */
export type NodeHeaderActionsProps = HTMLAttributes<HTMLDivElement>;
/**
* A container for right-aligned action buttons in the node header.
*/
export const NodeHeaderActions = forwardRef<
HTMLDivElement,
NodeHeaderActionsProps
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
{...props}
className={cn(
"ml-auto flex items-center gap-1 justify-self-end",
className,
)}
/>
);
});
NodeHeaderActions.displayName = "NodeHeaderActions";
/* NODE HEADER ACTION ------------------------------------------------------- */
export type NodeHeaderActionProps = React.ComponentProps<"button"> & {
label: string;
};
/**
* A thin wrapper around the `<Button />` component with a fixed sized suitable
* for icons.
*
* Because the `<NodeHeaderAction />` component is intended to render icons, it's
* important to provide a meaningful and accessible `label` prop that describes
* the action.
*/
export const NodeHeaderAction = forwardRef<
HTMLButtonElement,
NodeHeaderActionProps
>(({ className, label, title, ...props }, ref) => {
return (
<Button
ref={ref}
variant="ghost"
aria-label={label}
title={title ?? label}
className={cn(className, "nodrag size-6 p-1")}
{...props}
/>
);
});
NodeHeaderAction.displayName = "NodeHeaderAction";
//
export type NodeHeaderMenuActionProps = Omit<
NodeHeaderActionProps,
"onClick"
> & {
trigger?: ReactNode;
};
/**
* Renders a header action that opens a dropdown menu when clicked. The dropdown
* trigger is a button with an ellipsis icon. The trigger's content can be changed
* by using the `trigger` prop.
*
* Any children passed to the `<NodeHeaderMenuAction />` component will be rendered
* inside the dropdown menu. You can read the docs for the shadcn dropdown menu
* here: https://ui.shadcn.com/docs/components/dropdown-menu
*
*/
export const NodeHeaderMenuAction = forwardRef<
HTMLButtonElement,
NodeHeaderMenuActionProps
>(({ trigger, children, ...props }, ref) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<NodeHeaderAction ref={ref} {...props}>
{trigger ?? <EllipsisVertical />}
</NodeHeaderAction>
</DropdownMenuTrigger>
<DropdownMenuContent>{children}</DropdownMenuContent>
</DropdownMenu>
);
});
NodeHeaderMenuAction.displayName = "NodeHeaderMenuAction";
/* NODE HEADER DELETE ACTION --------------------------------------- */
export const NodeHeaderDeleteAction = () => {
const id = useNodeId();
const { setNodes } = useReactFlow();
const handleClick = useCallback(() => {
setNodes((prevNodes) => prevNodes.filter((node) => node.id !== id));
}, [id, setNodes]);
return (
<NodeHeaderAction onClick={handleClick} label="Delete node">
<Trash />
</NodeHeaderAction>
);
};
NodeHeaderDeleteAction.displayName = "NodeHeaderDeleteAction";

View file

@ -0,0 +1,292 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Play } from "lucide-react";
import { memo, 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 { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { NodeContent } from "./common/NodeContent";
import { NodeEditDialog } from "./common/NodeEditDialog";
import { useNodeHandlers } from "./common/useNodeHandlers";
interface StartCallEditFormProps {
nodeData: FlowNodeData;
prompt: string;
setPrompt: (value: string) => void;
isStatic: boolean;
setIsStatic: (value: boolean) => void;
name: string;
setName: (value: string) => void;
allowInterrupt: boolean;
setAllowInterrupt: (value: boolean) => void;
addGlobalPrompt: boolean;
setAddGlobalPrompt: (value: boolean) => void;
waitForUserResponse: boolean;
setWaitForUserResponse: (value: boolean) => void;
detectVoicemail: boolean;
setDetectVoicemail: (value: boolean) => void;
delayedStart: boolean;
setDelayedStart: (value: boolean) => void;
delayedStartDuration: number;
setDelayedStartDuration: (value: number) => void;
}
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 } = useWorkflow();
// Form state
const [prompt, setPrompt] = useState(data.prompt ?? "");
const [isStatic, setIsStatic] = useState(data.is_static ?? true);
const [name, setName] = useState(data.name);
const [allowInterrupt, setAllowInterrupt] = useState(data.allow_interrupt ?? true);
const [addGlobalPrompt, setAddGlobalPrompt] = useState(data.add_global_prompt ?? true);
const [waitForUserResponse, setWaitForUserResponse] = useState(data.wait_for_user_response ?? false);
const [detectVoicemail, setDetectVoicemail] = useState(data.detect_voicemail ?? true);
const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false);
const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2);
const handleSave = async () => {
handleSaveNodeData({
...data,
prompt,
is_static: isStatic,
name,
allow_interrupt: allowInterrupt,
add_global_prompt: addGlobalPrompt,
wait_for_user_response: waitForUserResponse,
detect_voicemail: detectVoicemail,
delayed_start: delayedStart,
delayed_start_duration: delayedStart ? delayedStartDuration : undefined
});
setOpen(false);
// Save the workflow after updating node data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
};
// Reset form state when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPrompt(data.prompt ?? "");
setIsStatic(data.is_static ?? true);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setWaitForUserResponse(data.wait_for_user_response ?? false);
setDetectVoicemail(data.detect_voicemail ?? true);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
}
setOpen(newOpen);
};
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
title="Start Call"
icon={<Play />}
bgColor="bg-green-300"
hasSourceHandle={true}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
</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}
>
{open && (
<StartCallEditForm
nodeData={data}
prompt={prompt}
setPrompt={setPrompt}
isStatic={isStatic}
setIsStatic={setIsStatic}
name={name}
setName={setName}
allowInterrupt={allowInterrupt}
setAllowInterrupt={setAllowInterrupt}
addGlobalPrompt={addGlobalPrompt}
setAddGlobalPrompt={setAddGlobalPrompt}
waitForUserResponse={waitForUserResponse}
setWaitForUserResponse={setWaitForUserResponse}
detectVoicemail={detectVoicemail}
setDetectVoicemail={setDetectVoicemail}
delayedStart={delayedStart}
setDelayedStart={setDelayedStart}
delayedStartDuration={delayedStartDuration}
setDelayedStartDuration={setDelayedStartDuration}
/>
)}
</NodeEditDialog>
</>
);
});
const StartCallEditForm = ({
prompt,
setPrompt,
isStatic,
setIsStatic,
name,
setName,
allowInterrupt,
setAllowInterrupt,
addGlobalPrompt,
setAddGlobalPrompt,
waitForUserResponse,
setWaitForUserResponse,
detectVoicemail,
setDetectVoicemail,
delayedStart,
setDelayedStart,
delayedStartDuration,
setDelayedStartDuration
}: StartCallEditFormProps) => {
return (
<div className="grid gap-2">
<Label>Name</Label>
<Label className="text-xs text-gray-500">
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>{isStatic ? "Text" : "Prompt"}</Label>
<Label className="text-xs text-gray-500">
What would you like the agent to say when the call starts? Its a good idea to have a static greeting that can be used to identify the call.
</Label>
<div className="flex items-center space-x-2">
<Switch id="static-text" checked={isStatic} onCheckedChange={setIsStatic} />
<Label htmlFor="static-text">Static Text</Label>
</div>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] max-h-[300px] resize-none"
style={{
overflowY: 'auto'
}}
placeholder={isStatic ? "Hello, welcome to Dograh. How can I help you today?" : "Enter a dynamic prompt"}
/>
<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-gray-500">
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}
disabled={isStatic}
/>
<Label htmlFor="add-global-prompt" className={isStatic ? "opacity-50" : ""}>
Add Global Prompt
</Label>
<Label className={`text-xs text-gray-500 ${isStatic ? "opacity-50" : ""}`}>
{isStatic
? "Not applicable for static text"
: "Whether you want to add global prompt with this node's prompt."}
</Label>
</div>
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Switch
id="wait-for-user-response"
checked={waitForUserResponse}
onCheckedChange={setWaitForUserResponse}
disabled={!isStatic}
/>
<Label htmlFor="wait-for-user-response" className={!isStatic ? "opacity-50" : ""}>
Wait for user&apos;s response
</Label>
<Label className={`text-xs text-gray-500 ${!isStatic ? "opacity-50" : ""}`}>
{!isStatic
? "Only applicable for static text"
: "Wait for user to respond before disconnecting the call."}
</Label>
</div>
</div>
<div className="flex items-center space-x-2">
<Switch
id="detect-voicemail"
checked={detectVoicemail}
onCheckedChange={setDetectVoicemail}
/>
<Label htmlFor="detect-voicemail">
Detect Voicemail
</Label>
<Label className="text-xs text-gray-500">
Automatically detect and end call if voicemail is reached.
</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-gray-500">
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>
</div>
);
};
StartCall.displayName = "StartCall";

View file

@ -0,0 +1,44 @@
import { Position } from "@xyflow/react";
import { ReactNode } from "react";
import { BaseHandle } from "@/components/flow/nodes/BaseHandle";
import { BaseNode } from "@/components/flow/nodes/BaseNode";
import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/nodes/NodeHeader";
interface NodeContentProps {
selected: boolean;
invalid?: boolean;
title: string;
icon: ReactNode;
bgColor: string;
hasSourceHandle?: boolean;
hasTargetHandle?: boolean;
children?: ReactNode;
className?: string;
}
export const NodeContent = ({
selected,
invalid,
title,
icon,
bgColor,
hasSourceHandle = false,
hasTargetHandle = false,
children,
className = "",
}: NodeContentProps) => {
return (
<BaseNode selected={selected} invalid={invalid} className={`p-0 overflow-hidden ${className}`}>
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
<NodeHeaderTitle>{title}</NodeHeaderTitle>
</NodeHeader>
<div className="p-3">
{children}
</div>
{hasSourceHandle && <BaseHandle type="source" position={Position.Bottom} />}
</BaseNode>
);
};

View file

@ -0,0 +1,63 @@
import { AlertCircle } from "lucide-react";
import { ReactNode } from "react";
import { FlowNodeData } from "@/components/flow/types";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
interface NodeEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
nodeData: FlowNodeData;
title: string;
children: ReactNode;
onSave?: () => void;
}
export const NodeEditDialog = ({
open,
onOpenChange,
nodeData,
title,
children,
onSave
}: NodeEditDialogProps) => {
const handleClose = () => onOpenChange(false);
const handleSave = () => {
if (onSave) {
onSave();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto"
style={{ maxWidth: "1200px", width: "95vw" }}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Configure the settings for this node in your workflow.
</DialogDescription>
{nodeData.invalid && nodeData.validationMessage && (
<div className="mt-2 flex items-center gap-2 rounded-md bg-red-50 p-2 text-sm text-red-500 border border-red-200">
<AlertCircle className="h-4 w-4" />
<span>{nodeData.validationMessage}</span>
</div>
)}
</DialogHeader>
<div className="grid gap-4 py-4">
{children}
</div>
<DialogFooter>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,39 @@
import { useReactFlow } from "@xyflow/react";
import { useCallback, useState } from "react";
import { FlowEdge, FlowNode, FlowNodeData } from "@/components/flow/types";
interface UseNodeHandlersProps {
id: string;
additionalData?: Record<string, string | boolean>;
}
export const useNodeHandlers = ({ id, additionalData = {} }: UseNodeHandlersProps) => {
const [open, setOpen] = useState(false);
const { setNodes } = useReactFlow<FlowNode, FlowEdge>();
const handleSaveNodeData = useCallback(
(updatedData: FlowNodeData) => {
setNodes((nodes) => {
const updatedNodes = nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, ...updatedData, ...additionalData } }
: node
);
return updatedNodes;
});
},
[id, setNodes, additionalData]
);
const handleDeleteNode = useCallback(() => {
setNodes((nodes) => nodes.filter((node) => node.id !== id));
}, [id, setNodes]);
return {
open,
setOpen,
handleSaveNodeData,
handleDeleteNode,
};
};

View file

@ -0,0 +1,4 @@
export * from './AgentNode';
export * from './EndCall';
export * from './GlobalNode';
export * from './StartCall';

View file

@ -0,0 +1,86 @@
export enum NodeType {
START_CALL = 'startCall',
AGENT_NODE = 'agentNode',
END_CALL = 'endCall',
GLOBAL_NODE = 'globalNode'
}
export type FlowNodeData = {
prompt: string;
name: string;
is_start?: boolean;
is_static?: boolean;
is_end?: boolean;
invalid?: boolean;
validationMessage?: string | null;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;
extraction_variables?: ExtractionVariable[];
add_global_prompt?: boolean;
wait_for_user_response?: boolean;
wait_for_user_response_timeout?: number;
wait_for_user_greeting?: boolean;
detect_voicemail?: boolean;
delayed_start?: boolean;
delayed_start_duration?: number;
}
export type FlowNode = {
id: string;
type: string;
position: { x: number; y: number };
data: FlowNodeData;
measured?: {
width: number;
height: number;
};
selected?: boolean;
dragging?: boolean;
};
export type FlowEdgeData = {
condition: string;
label: string;
invalid?: boolean;
validationMessage?: string | null;
}
export type FlowEdge = {
id: string;
source: string;
target: string;
type?: string;
data: FlowEdgeData;
animated?: boolean;
invalid?: boolean;
};
export interface WorkflowDefinition {
nodes: FlowNode[];
edges: FlowEdge[];
viewport: {
x: number;
y: number;
zoom: number;
};
}
export interface WorkflowData {
name: string;
workflow_definition: WorkflowDefinition;
}
export type WorkflowValidationError = {
kind: 'node' | 'edge' | 'workflow';
id: string;
field: string;
message: string;
}
export type ExtractionVariable = {
name: string;
type: 'string' | 'number' | 'boolean';
prompt?: string;
};