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,6 +1,6 @@
'use client';
import { FileText, Upload, X } from 'lucide-react';
import { FileText, Info, Upload, X } from 'lucide-react';
import { useRef, useState } from 'react';
import { toast } from 'sonner';
@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useAppConfig } from '@/context/AppConfigContext';
import logger from '@/lib/logger';
interface DocumentUploadProps {
@ -23,6 +24,8 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['.pdf', '.docx', '.doc', '.txt', '.json'];
export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps) {
const { config } = useAppConfig();
const isOSS = config?.deploymentMode === 'oss';
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [retrievalMode, setRetrievalMode] = useState<string>('full_document');
const [uploading, setUploading] = useState(false);
@ -30,6 +33,21 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const ossNotice = isOSS ? (
<div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/30">
<Info className="h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400 mt-0.5" />
<div className="text-xs text-amber-900 dark:text-amber-200">
<p className="font-medium">Processed by an external service</p>
<p className="mt-1">
Uploaded documents are sent to Dograh&apos;s managed Model Proxy Service for
parsing and chunking. Dograh Model Proxy Service does not store or read your documents -
the extracted text and embeddings are returned and stored locally in your
self-hosted database.
</p>
</div>
</div>
) : null;
const validateFile = (file: File): boolean => {
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!ACCEPTED_FILE_TYPES.includes(fileExtension)) {
@ -164,6 +182,7 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
if (selectedFile && !uploading) {
return (
<div className="space-y-4">
{ossNotice}
{/* Selected file info */}
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/30">
<FileText className="w-8 h-8 text-primary flex-shrink-0" />
@ -225,6 +244,7 @@ export default function DocumentUpload({ onUploadSuccess }: DocumentUploadProps)
return (
<div className="space-y-4">
{ossNotice}
<input
ref={fileInputRef}
type="file"

View file

@ -19,7 +19,7 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, QANode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
import { GenericNode } from "../../../components/flow/nodes/GenericNode";
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
@ -27,16 +27,13 @@ import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
// Define the node types dynamically based on the onSave prop
const nodeTypes = {
[NodeType.START_CALL]: StartCall,
[NodeType.AGENT_NODE]: AgentNode,
[NodeType.END_CALL]: EndCall,
[NodeType.GLOBAL_NODE]: GlobalNode,
[NodeType.TRIGGER]: TriggerNode,
[NodeType.WEBHOOK]: WebhookNode,
[NodeType.QA]: QANode,
};
// Single generic component for every node type. The spec catalog
// (`/api/v1/node-types`) drives form rendering, canvas preview, handles,
// and defaults. Adding a new node type means adding a Python NodeSpec —
// no React changes required.
const nodeTypes = Object.fromEntries(
Object.values(NodeType).map((t) => [t, GenericNode]),
);
const edgeTypes = {
custom: CustomEdge,

View file

@ -17,141 +17,54 @@ import {
updateWorkflowApiV1WorkflowWorkflowIdPut,
validateWorkflowApiV1WorkflowWorkflowIdValidatePost
} from "@/client";
import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { NodeSpec, WorkflowError } from "@/client/types.gen";
import { useNodeSpecs } from "@/components/flow/renderer";
import { FlowEdge, FlowNode, FlowNodeData, NodeType } from "@/components/flow/types";
import { PostHogEvent } from "@/constants/posthog-events";
import logger from '@/lib/logger';
import { getNextNodeId, getRandomId } from "@/lib/utils";
import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from "@/types/workflow-configurations";
const DEFAULT_QA_SYSTEM_PROMPT = `You are a QA analyst evaluating a specific segment of a voice AI conversation.
## Node Purpose
{{node_summary}}
## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.)
{{previous_conversation_summary}}
## Tags to evaluate
Examine the conversation carefully and identify which of the following tags apply:
- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically
- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself
- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said
- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call
- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification
- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?")
- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge)
- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill
- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda.
- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human.
## Call metrics (pre-computed)
Use these alongside the transcript for your analysis:
{{metrics}}
## Output format
Return ONLY a valid JSON object (no markdown):
{
"tags": [
{
"tag": "TAG_NAME",
"reason": "Short reason with evidence from the transcript"
// Build initial node data from spec defaults. Replaces the per-type
// hardcoded `getNewNode` switch — adding a new node type is now zero
// frontend code: declare the spec on the backend and the defaults flow
// through here.
function buildDataFromSpec(spec: NodeSpec): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (const prop of spec.properties) {
if (prop.default !== undefined && prop.default !== null) {
data[prop.name] = prop.default;
}
],
"overall_sentiment": "positive|neutral|negative",
"call_quality_score": <1-10>,
"summary": "1-2 sentence summary of this segment"
}
If no tags apply, return an empty tags list. Always provide sentiment, score, and summary.`;
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
switch (type) {
case NodeType.AGENT_NODE:
return true; // Agents can be interrupted
case NodeType.START_CALL:
case NodeType.END_CALL:
return false; // Start/End messages should not be interrupted
default:
return false;
}
return data;
}
const defaultNodes: FlowNode[] = [
{
id: "1",
type: NodeType.START_CALL,
position: { x: 200, y: 200 },
data: {
prompt: "",
name: "",
allow_interrupt: getDefaultAllowInterrupt(NodeType.START_CALL),
},
},
];
const getNewNode = (type: string, position: { x: number, y: number }, existingNodes: FlowNode[]) => {
// Base node configuration
const baseNode = {
function buildNewNode(
type: string,
position: { x: number; y: number },
existingNodes: FlowNode[],
spec: NodeSpec,
): FlowNode {
const data = buildDataFromSpec(spec) as Partial<FlowNodeData> & Record<string, unknown>;
if (type === NodeType.START_CALL) data.is_start = true;
if (type === NodeType.END_CALL) data.is_end = true;
return {
id: getNextNodeId(existingNodes),
type,
position,
data: {
prompt: {
[NodeType.GLOBAL_NODE]: "You are a helpful assistant whose mode of interaction with the user is voice. So don't use any special characters which can not be pronounced. Use short sentences and simple language.",
}[type] || "",
name: {
[NodeType.GLOBAL_NODE]: "Global Node",
[NodeType.START_CALL]: "Start Call",
[NodeType.END_CALL]: "End Call",
[NodeType.WEBHOOK]: "Webhook",
[NodeType.QA]: "QA Analysis",
}[type] || "",
allow_interrupt: getDefaultAllowInterrupt(type),
},
data: data as unknown as FlowNode["data"],
};
}
// Add webhook-specific defaults
if (type === NodeType.WEBHOOK) {
return {
...baseNode,
data: {
...baseNode.data,
enabled: true,
http_method: "POST" as const,
endpoint_url: "",
custom_headers: [],
payload_template: {
call_id: "{{workflow_run_id}}",
first_name: "{{initial_context.first_name}}",
rsvp: "{{gathered_context.rsvp}}",
duration: "{{cost_info.call_duration_seconds}}",
recording_url: "{{recording_url}}",
transcript_url: "{{transcript_url}}",
},
},
};
}
// Add QA-specific defaults
if (type === NodeType.QA) {
return {
...baseNode,
data: {
...baseNode.data,
qa_enabled: true,
qa_model: "default",
qa_system_prompt: DEFAULT_QA_SYSTEM_PROMPT,
},
};
}
return baseNode;
};
// Look up the spec default for `allow_interrupt`. Used as a load-time
// fallback for older saved workflows whose nodes lack the field.
function specAllowInterrupt(
type: string,
bySpecName: Map<string, NodeSpec>,
): boolean | undefined {
const prop = bySpecName.get(type)?.properties.find((p) => p.name === "allow_interrupt");
return prop?.default as boolean | undefined;
}
interface UseWorkflowStateProps {
initialWorkflowName: string;
@ -181,6 +94,10 @@ export const useWorkflowState = ({
const router = useRouter();
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
// Spec catalog. Workflow init waits on this to populate defaults; node
// creation looks up per-type schemas through it.
const { bySpecName, loading: specsLoading } = useNodeSpecs();
// Get state and actions from the store
const {
nodes,
@ -214,20 +131,32 @@ export const useWorkflowState = ({
const canUndo = useWorkflowStore((state) => state.canUndo());
const canRedo = useWorkflowStore((state) => state.canRedo());
// Initialize workflow on mount
// Initialize workflow on mount. Waits for the spec catalog so defaults
// (allow_interrupt, prompt placeholders, etc.) come from one source.
useEffect(() => {
if (specsLoading) return;
const startSpec = bySpecName.get(NodeType.START_CALL);
const fallbackStartNodes: FlowNode[] = startSpec
? [buildNewNode(NodeType.START_CALL, { x: 200, y: 200 }, [], startSpec)]
: [];
const initialNodes = initialFlow?.nodes?.length
? initialFlow.nodes.map(node => ({
...node,
data: {
...node.data,
invalid: false,
allow_interrupt: node.data.allow_interrupt !== undefined
? node.data.allow_interrupt
: getDefaultAllowInterrupt(node.type),
}
}))
: defaultNodes;
? initialFlow.nodes.map((node) => {
const fallbackAllowInterrupt = specAllowInterrupt(node.type, bySpecName) ?? false;
return {
...node,
data: {
...node.data,
invalid: false,
allow_interrupt:
node.data.allow_interrupt !== undefined
? node.data.allow_interrupt
: fallbackAllowInterrupt,
},
};
})
: fallbackStartNodes;
initializeWorkflow(
workflowId,
@ -238,7 +167,7 @@ export const useWorkflowState = ({
initialWorkflowConfigurations,
initialWorkflowConfigurations?.dictionary ?? ''
);
}, [workflowId, initialWorkflowName, initialFlow?.nodes, initialFlow?.edges, initialTemplateContextVariables, initialWorkflowConfigurations, initializeWorkflow]);
}, [workflowId, initialWorkflowName, initialFlow?.nodes, initialFlow?.edges, initialTemplateContextVariables, initialWorkflowConfigurations, initializeWorkflow, specsLoading, bySpecName]);
// Set up keyboard shortcuts for undo/redo
useEffect(() => {
@ -280,8 +209,13 @@ export const useWorkflowState = ({
y: window.innerHeight / 2,
});
const spec = bySpecName.get(nodeType);
if (!spec) {
logger.warn({ nodeType }, "No spec registered for node type — cannot add");
return;
}
const newNode = {
...getNewNode(nodeType, position, nodes),
...buildNewNode(nodeType, position, nodes, spec),
selected: true, // Mark the new node as selected
};
@ -297,7 +231,7 @@ export const useWorkflowState = ({
workflow_id: workflowId,
});
setIsAddNodePanelOpen(false);
}, [nodes, setIsAddNodePanelOpen, workflowId]);
}, [nodes, setIsAddNodePanelOpen, workflowId, bySpecName]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1281,6 +1281,36 @@ export type DefaultConfigurationsResponse = {
};
};
/**
* DisplayOptions
*
* Conditional visibility rules.
*
* `show` keys are AND-combined: this property is visible only when EVERY
* referenced field's value matches one of the listed values.
*
* `hide` keys are OR-combined: this property is hidden when ANY referenced
* field's value matches one of the listed values.
*
* Example:
* DisplayOptions(show={"extraction_enabled": [True]})
* DisplayOptions(show={"greeting_type": ["audio"]})
*/
export type DisplayOptions = {
/**
* Show
*/
show?: {
[key: string]: Array<unknown>;
} | null;
/**
* Hide
*/
hide?: {
[key: string]: Array<unknown>;
} | null;
};
/**
* DocumentListResponseSchema
*
@ -1675,6 +1705,30 @@ export type FileMetadataResponse = {
} | null;
};
/**
* GraphConstraints
*
* Per-node-type graph rules. WorkflowGraph enforces these at validation.
*/
export type GraphConstraints = {
/**
* Min Incoming
*/
min_incoming?: number | null;
/**
* Max Incoming
*/
max_incoming?: number | null;
/**
* Min Outgoing
*/
min_outgoing?: number | null;
/**
* Max Outgoing
*/
max_outgoing?: number | null;
};
/**
* HTTPValidationError
*/
@ -2056,6 +2110,123 @@ export type MpsCreditsResponse = {
total_quota: number;
};
/**
* MigrationSpec
*
* Declared migration step (JSON-serializable view).
*
* The migrate callable is registered out-of-band via `register_migration()`
* and never serialized LLM and frontend consumers only see version
* metadata and warn on mismatch.
*/
export type MigrationSpec = {
/**
* From Version
*/
from_version: string;
/**
* To Version
*/
to_version: string;
/**
* Description
*/
description: string;
};
/**
* NodeCategory
*
* Drives grouping in the AddNodePanel UI.
*/
export type NodeCategory = 'call_node' | 'global_node' | 'trigger' | 'integration';
/**
* NodeExample
*
* A worked example LLMs can pattern-match. Keep small and realistic.
*/
export type NodeExample = {
/**
* Name
*/
name: string;
/**
* Description
*/
description?: string | null;
/**
* Data
*/
data: {
[key: string]: unknown;
};
};
/**
* NodeSpec
*
* Single source of truth for a node type.
*/
export type NodeSpec = {
/**
* Name
*/
name: string;
/**
* Display Name
*/
display_name: string;
/**
* Description
*
* Human-facing explanation shown in AddNodePanel.
*/
description: string;
/**
* Llm Hint
*
* LLM-only guidance; omitted from the UI.
*/
llm_hint?: string | null;
category: NodeCategory;
/**
* Icon
*/
icon: string;
/**
* Version
*/
version?: string;
/**
* Properties
*/
properties: Array<PropertySpec>;
/**
* Examples
*/
examples?: Array<NodeExample>;
/**
* Migrations
*/
migrations?: Array<MigrationSpec>;
graph_constraints?: GraphConstraints | null;
};
/**
* NodeTypesResponse
*/
export type NodeTypesResponse = {
/**
* Spec Version
*/
spec_version: string;
/**
* Node Types
*/
node_types: Array<NodeSpec>;
};
/**
* PresignedUploadUrlRequest
*/
@ -2124,6 +2295,125 @@ export type ProcessDocumentRequestSchema = {
retrieval_mode?: string;
};
/**
* PropertyOption
*
* An option in an `options` or `multi_options` dropdown.
*/
export type PropertyOption = {
/**
* Value
*/
value: string | number | boolean | number;
/**
* Label
*/
label: string;
/**
* Description
*/
description?: string | null;
};
/**
* PropertySpec
*
* Single field on a node.
*
* `description` is HUMAN-FACING shown under the field in the edit
* dialog. Keep it concise and explain what the field does.
*
* `llm_hint` is LLM-FACING appears only in the `get_node_type` MCP
* response and in SDK schema output. Use it for catalog tool references
* (e.g., "Use `list_recordings`"), array shape, expected value idioms,
* or anything that would be noise in the UI. Optional; omit when the
* `description` already suffices for both audiences.
*/
export type PropertySpec = {
/**
* Name
*/
name: string;
type: PropertyType;
/**
* Display Name
*/
display_name: string;
/**
* Description
*
* Human-facing explanation shown in the UI.
*/
description: string;
/**
* Llm Hint
*
* LLM-only guidance; omitted from the UI.
*/
llm_hint?: string | null;
/**
* Default
*/
default?: unknown;
/**
* Required
*/
required?: boolean;
/**
* Placeholder
*/
placeholder?: string | null;
display_options?: DisplayOptions | null;
/**
* Options
*/
options?: Array<PropertyOption> | null;
/**
* Properties
*/
properties?: Array<PropertySpec> | null;
/**
* Min Value
*/
min_value?: number | null;
/**
* Max Value
*/
max_value?: number | null;
/**
* Min Length
*/
min_length?: number | null;
/**
* Max Length
*/
max_length?: number | null;
/**
* Pattern
*/
pattern?: string | null;
/**
* Editor
*/
editor?: string | null;
/**
* Extra
*/
extra?: {
[key: string]: unknown;
};
};
/**
* PropertyType
*
* Bounded vocabulary of property types the renderer dispatches on.
*
* Adding a value here requires a matching arm in the frontend
* `<PropertyInput>` switch and (where relevant) the SDK codegen template.
*/
export type PropertyType = 'string' | 'number' | 'boolean' | 'options' | 'multi_options' | 'fixed_collection' | 'json' | 'tool_refs' | 'document_refs' | 'recording_ref' | 'credential_ref' | 'mention_textarea' | 'url';
/**
* RecordingCreateRequestSchema
*
@ -9385,6 +9675,89 @@ export type GetCurrentUserApiV1AuthMeGetResponses = {
export type GetCurrentUserApiV1AuthMeGetResponse = GetCurrentUserApiV1AuthMeGetResponses[keyof GetCurrentUserApiV1AuthMeGetResponses];
export type ListNodeTypesApiV1NodeTypesGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/node-types';
};
export type ListNodeTypesApiV1NodeTypesGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListNodeTypesApiV1NodeTypesGetError = ListNodeTypesApiV1NodeTypesGetErrors[keyof ListNodeTypesApiV1NodeTypesGetErrors];
export type ListNodeTypesApiV1NodeTypesGetResponses = {
/**
* Successful Response
*/
200: NodeTypesResponse;
};
export type ListNodeTypesApiV1NodeTypesGetResponse = ListNodeTypesApiV1NodeTypesGetResponses[keyof ListNodeTypesApiV1NodeTypesGetResponses];
export type GetNodeTypeApiV1NodeTypesNameGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Name
*/
name: string;
};
query?: never;
url: '/api/v1/node-types/{name}';
};
export type GetNodeTypeApiV1NodeTypesNameGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetNodeTypeApiV1NodeTypesNameGetError = GetNodeTypeApiV1NodeTypesNameGetErrors[keyof GetNodeTypeApiV1NodeTypesNameGetErrors];
export type GetNodeTypeApiV1NodeTypesNameGetResponses = {
/**
* Successful Response
*/
200: NodeSpec;
};
export type GetNodeTypeApiV1NodeTypesNameGetResponse = GetNodeTypeApiV1NodeTypesNameGetResponses[keyof GetNodeTypeApiV1NodeTypesNameGetResponses];
export type HealthApiV1HealthGetData = {
body?: never;
path?: never;

View file

@ -1,118 +1,91 @@
import { ClipboardCheck, ExternalLink, Globe, Headset, Link2, LucideIcon, OctagonX, Play, Webhook, X } from 'lucide-react';
import { useEffect } from 'react';
import * as LucideIcons from 'lucide-react';
import { Circle, ExternalLink, type LucideIcon, X } from 'lucide-react';
import { useEffect, useMemo } from 'react';
import type { NodeSpec } from '@/client/types.gen';
import { useNodeSpecs } from '@/components/flow/renderer';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
type NodeTypeConfig = {
type: NodeType;
label: string;
description: string;
icon: LucideIcon;
};
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
};
const NODE_TYPES: NodeTypeConfig[] = [
{
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
}
// Section ordering and labels. Drives both the category → section title
// mapping and the rendering order.
const SECTION_ORDER: Array<{ category: NodeSpec['category']; title: string }> = [
{ category: 'trigger', title: 'Triggers' },
{ category: 'call_node', title: 'Agent Nodes' },
{ category: 'global_node', title: 'Global Nodes' },
{ category: 'integration', title: 'Integrations' },
];
const GLOBAL_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.GLOBAL_NODE,
label: 'Global Node',
description: 'Create a global node',
icon: Globe
}
];
const TRIGGER_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.TRIGGER,
label: 'API Trigger',
description: 'Enable API-based call triggering',
icon: Webhook
}
];
const INTEGRATION_NODE_TYPES: NodeTypeConfig[] = [
{
type: NodeType.WEBHOOK,
label: 'Webhook',
description: 'Send HTTP request after workflow completion',
icon: Link2
},
{
type: NodeType.QA,
label: 'QA Analysis',
description: 'Run LLM quality analysis after each call',
icon: ClipboardCheck
}
];
function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? Circle;
}
function NodeSection({
title,
nodes,
onNodeSelect
specs,
onNodeSelect,
}: {
title: string;
nodes: NodeTypeConfig[];
specs: NodeSpec[];
onNodeSelect: (nodeType: NodeType) => void;
}) {
if (specs.length === 0) return null;
return (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</h3>
<div className="space-y-2">
{nodes.map((node) => (
<Button
key={node.type}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(node.type)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<node.icon className="h-5 w-5" />
{specs.map((spec) => {
const Icon = resolveIcon(spec.icon);
return (
<Button
key={spec.name}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(spec.name as NodeType)}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
<Icon className="h-5 w-5" />
</div>
<div className="flex flex-col items-start text-left min-w-0">
<span className="font-medium text-sm">
{spec.display_name}
</span>
<span className="text-xs text-muted-foreground whitespace-normal">
{spec.description}
</span>
</div>
</div>
<div className="flex flex-col items-start text-left min-w-0">
<span className="font-medium text-sm">{node.label}</span>
<span className="text-xs text-muted-foreground whitespace-normal">
{node.description}
</span>
</div>
</div>
</Button>
))}
</Button>
);
})}
</div>
</div>
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
const { specs } = useNodeSpecs();
// Group registered specs by category, preserving the SECTION_ORDER.
// Adding a new node type with a new spec.category just shows up here.
const sections = useMemo(() => {
return SECTION_ORDER.map(({ category, title }) => ({
title,
specs: specs.filter((s) => s.category === category),
}));
}, [specs]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
@ -149,29 +122,14 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
</div>
<div className="space-y-6">
<NodeSection
title="Triggers"
nodes={TRIGGER_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<NodeSection
title="Agent Nodes"
nodes={NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<NodeSection
title="Global Nodes"
nodes={GLOBAL_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
<NodeSection
title="Integrations"
nodes={INTEGRATION_NODE_TYPES}
onNodeSelect={onNodeSelect}
/>
{sections.map(({ title, specs }) => (
<NodeSection
key={title}
title={title}
specs={specs}
onNodeSelect={onNodeSelect}
/>
))}
</div>
</div>
</div>

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';

View file

@ -0,0 +1,48 @@
import { useCallback } from "react";
import type { NodeSpec } from "@/client/types.gen";
import { evaluateDisplayOptions } from "./displayOptions";
import { PropertyInput, type RendererContext } from "./PropertyInput";
export interface NodeEditFormProps {
spec: NodeSpec;
/** Current form values keyed by property name. */
values: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
context: RendererContext;
}
/**
* Generic node-edit form. Walks `spec.properties` once, evaluates each
* property's `display_options` against current values, and renders the
* visible properties through `<PropertyInput>`.
*
* Wire format compatibility: form `values` are flat (matching the wire
* format), so `display_options` references work directly. Sub-objects from
* grouped fields (e.g. `pre_call_fetch`) live as separate flat fields here.
*/
export function NodeEditForm({ spec, values, onChange, context }: NodeEditFormProps) {
const setProp = useCallback(
(propName: string, propValue: unknown) => {
onChange({ ...values, [propName]: propValue });
},
[values, onChange],
);
return (
<div className="grid gap-3">
{spec.properties
.filter((p) => evaluateDisplayOptions(p.display_options, values))
.map((p) => (
<PropertyInput
key={p.name}
spec={p}
value={values[p.name]}
onChange={(v) => setProp(p.name, v)}
context={context}
/>
))}
</div>
);
}

View file

@ -0,0 +1,462 @@
import { PlusIcon, Trash2Icon } from "lucide-react";
import type {
DocumentResponseSchema,
PropertySpec,
RecordingResponseSchema,
ToolResponse,
} from "@/client/types.gen";
import { DocumentSelector } from "@/components/flow/DocumentSelector";
import { MentionTextarea } from "@/components/flow/MentionTextarea";
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
import { ToolSelector } from "@/components/flow/ToolSelector";
import { CredentialSelector, UrlInput } from "@/components/http";
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 { evaluateDisplayOptions } from "./displayOptions";
export interface RendererContext {
tools: ToolResponse[];
documents: DocumentResponseSchema[];
recordings: RecordingResponseSchema[];
}
export interface PropertyInputProps {
spec: PropertySpec;
value: unknown;
onChange: (value: unknown) => void;
context: RendererContext;
}
/**
* Generic property dispatcher. Renders the right widget based on
* `spec.type` and the standard label/description layout. Widgets that
* already own their own label structure (Tool/DocumentSelector) are told
* to suppress it via `showLabel={false}`.
*
* Caller is responsible for evaluating `display_options` `PropertyInput`
* always renders. NodeEditForm filters out hidden properties before
* mounting them.
*/
export function PropertyInput({ spec, value, onChange, context }: PropertyInputProps) {
switch (spec.type) {
case "string":
return <StringWidget spec={spec} value={value} onChange={onChange} />;
case "number":
return <NumberWidget spec={spec} value={value} onChange={onChange} />;
case "boolean":
return <BooleanWidget spec={spec} value={value} onChange={onChange} />;
case "options":
return <OptionsWidget spec={spec} value={value} onChange={onChange} />;
case "multi_options":
return <MultiOptionsWidget spec={spec} value={value} onChange={onChange} />;
case "fixed_collection":
return (
<FixedCollectionWidget
spec={spec}
value={value}
onChange={onChange}
context={context}
/>
);
case "json":
return <JsonWidget spec={spec} value={value} onChange={onChange} />;
case "url":
return <UrlWidget spec={spec} value={value} onChange={onChange} />;
case "mention_textarea":
return (
<MentionWidget
spec={spec}
value={value}
onChange={onChange}
recordings={context.recordings}
/>
);
case "tool_refs":
return (
<ToolRefsWidget
spec={spec}
value={value}
onChange={onChange}
tools={context.tools}
/>
);
case "document_refs":
return (
<DocumentRefsWidget
spec={spec}
value={value}
onChange={onChange}
documents={context.documents}
/>
);
case "recording_ref":
return (
<RecordingRefWidget
spec={spec}
value={value}
onChange={onChange}
recordings={context.recordings}
/>
);
case "credential_ref":
return <CredentialRefWidget spec={spec} value={value} onChange={onChange} />;
default: {
const exhaustiveCheck: never = spec.type;
return (
<div className="text-xs text-destructive">
Unknown property type: {String(exhaustiveCheck)}
</div>
);
}
}
}
// ─── Layout helpers ──────────────────────────────────────────────────────
function StackedLabel({ spec }: { spec: PropertySpec }) {
return (
<>
<Label>
{spec.display_name}
{spec.required && <span className="text-destructive ml-1">*</span>}
</Label>
{spec.description && (
<Label className="text-xs text-muted-foreground">{spec.description}</Label>
)}
</>
);
}
// ─── Widgets ─────────────────────────────────────────────────────────────
interface WidgetProps {
spec: PropertySpec;
value: unknown;
onChange: (v: unknown) => void;
}
function StringWidget({ spec, value, onChange }: WidgetProps) {
const v = (value as string | undefined) ?? "";
const isMultiline = spec.editor === "textarea";
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
{isMultiline ? (
<Textarea
value={v}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder ?? undefined}
className="min-h-[80px] max-h-[200px] resize-none"
style={{ overflowY: "auto" }}
/>
) : (
<Input
value={v}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder ?? undefined}
/>
)}
</div>
);
}
function NumberWidget({ spec, value, onChange }: WidgetProps) {
const v = (value as number | undefined) ?? "";
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<Input
type="number"
value={v as number | string}
step={spec.min_value && spec.min_value < 1 ? 0.1 : 1}
min={spec.min_value ?? undefined}
max={spec.max_value ?? undefined}
onChange={(e) => {
const next = e.target.value;
onChange(next === "" ? undefined : parseFloat(next));
}}
placeholder={spec.placeholder ?? undefined}
className="w-32"
/>
</div>
);
}
function BooleanWidget({ spec, value, onChange }: WidgetProps) {
const v = !!value;
return (
<div className="flex items-center space-x-2">
<Switch id={`prop-${spec.name}`} checked={v} onCheckedChange={onChange} />
<Label htmlFor={`prop-${spec.name}`}>{spec.display_name}</Label>
{spec.description && (
<Label className="text-xs text-muted-foreground ml-2">
{spec.description}
</Label>
)}
</div>
);
}
function OptionsWidget({ spec, value, onChange }: WidgetProps) {
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<select
className="border rounded-md p-2 text-sm bg-background"
value={(value as string | number | undefined) ?? ""}
onChange={(e) => {
const raw = e.target.value;
const opt = spec.options?.find((o) => String(o.value) === raw);
onChange(opt?.value ?? raw);
}}
>
{spec.options?.map((o) => (
<option key={String(o.value)} value={String(o.value)}>
{o.label}
</option>
))}
</select>
</div>
);
}
function MultiOptionsWidget({ spec, value, onChange }: WidgetProps) {
const selected = new Set(((value as unknown[]) ?? []).map((v) => String(v)));
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<div className="flex flex-col gap-1 border rounded-md p-2">
{spec.options?.map((o) => {
const key = String(o.value);
return (
<label key={key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selected.has(key)}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(key);
else next.delete(key);
onChange(
spec.options
?.filter((opt) => next.has(String(opt.value)))
.map((opt) => opt.value) ?? [],
);
}}
/>
{o.label}
</label>
);
})}
</div>
</div>
);
}
function FixedCollectionWidget({
spec,
value,
onChange,
context,
}: WidgetProps & { context: RendererContext }) {
const rows = (value as Array<Record<string, unknown>> | undefined) ?? [];
const subProps = spec.properties ?? [];
const handleRowChange = (idx: number, propName: string, propValue: unknown) => {
const next = rows.map((row, i) =>
i === idx ? { ...row, [propName]: propValue } : row,
);
onChange(next);
};
const handleRemove = (idx: number) => {
onChange(rows.filter((_, i) => i !== idx));
};
const handleAdd = () => {
const blank: Record<string, unknown> = {};
for (const p of subProps) blank[p.name] = p.default ?? undefined;
onChange([...rows, blank]);
};
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<div className="space-y-2">
{rows.map((row, idx) => (
<div key={idx} className="border rounded-md p-2 bg-background space-y-2">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-2">
{subProps
.filter((sub) =>
evaluateDisplayOptions(sub.display_options, row),
)
.map((sub) => (
<PropertyInput
key={sub.name}
spec={sub}
value={row[sub.name]}
onChange={(v) =>
handleRowChange(idx, sub.name, v)
}
context={context}
/>
))}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handleRemove(idx)}
aria-label={`Remove row ${idx + 1}`}
>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</div>
))}
<Button variant="outline" size="sm" className="w-fit" onClick={handleAdd}>
<PlusIcon className="w-4 h-4 mr-1" /> Add
</Button>
</div>
</div>
);
}
function JsonWidget({ spec, value, onChange }: WidgetProps) {
// Render as a textarea with JSON serialization. Invalid JSON keeps the
// raw text so the user can finish editing without losing input.
const text = (() => {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
})();
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<Textarea
value={text}
onChange={(e) => {
const raw = e.target.value;
try {
onChange(raw === "" ? undefined : JSON.parse(raw));
} catch {
// Keep raw string in state until it parses; downstream
// serialization picks it up as-is.
onChange(raw);
}
}}
placeholder={spec.placeholder ?? "{ }"}
className="font-mono text-xs min-h-[120px]"
/>
</div>
);
}
function UrlWidget({ spec, value, onChange }: WidgetProps) {
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<UrlInput
value={(value as string | undefined) ?? ""}
onChange={onChange}
placeholder={spec.placeholder ?? undefined}
showValidation
/>
</div>
);
}
function MentionWidget({
spec,
value,
onChange,
recordings,
}: WidgetProps & { recordings: RecordingResponseSchema[] }) {
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<MentionTextarea
value={(value as string | undefined) ?? ""}
onChange={onChange}
placeholder={spec.placeholder ?? undefined}
className="min-h-[100px] max-h-[300px] resize-none overflow-y-auto"
recordings={recordings}
/>
</div>
);
}
function ToolRefsWidget({
spec,
value,
onChange,
tools,
}: WidgetProps & { tools: ToolResponse[] }) {
return (
<ToolSelector
value={(value as string[] | undefined) ?? []}
onChange={onChange}
tools={tools}
label={spec.display_name}
description={spec.description}
/>
);
}
function DocumentRefsWidget({
spec,
value,
onChange,
documents,
}: WidgetProps & { documents: DocumentResponseSchema[] }) {
return (
<DocumentSelector
value={(value as string[] | undefined) ?? []}
onChange={onChange}
documents={documents}
label={spec.display_name}
description={spec.description}
/>
);
}
function RecordingRefWidget({
spec,
value,
onChange,
recordings,
}: WidgetProps & { recordings: RecordingResponseSchema[] }) {
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<RecordingSelect
value={(value as string | undefined) ?? ""}
onChange={onChange}
recordings={recordings}
/>
</div>
);
}
function CredentialRefWidget({ spec, value, onChange }: WidgetProps) {
return (
<div className="grid gap-2">
<StackedLabel spec={spec} />
<CredentialSelector
value={(value as string | undefined) ?? ""}
onChange={onChange}
showLabel={false}
/>
</div>
);
}

View file

@ -0,0 +1,50 @@
// Structural type — kept local so this module has no dependencies and
// the parity-test script (`ui/scripts/test-display-options.mts`) can
// import it directly via tsx without alias resolution. The generated
// `DisplayOptions` from `@/client/types.gen` is structurally identical.
export type DisplayOptionsRule = {
show?: Record<string, unknown[]> | null;
hide?: Record<string, unknown[]> | null;
};
/**
* Evaluate a `display_options` rule against the current form values.
*
* `show` keys are AND-combined: visible only when EVERY referenced field's
* value matches one of the listed allowed values.
*
* `hide` keys are OR-combined: hidden when ANY referenced field's value
* matches one of the listed values.
*
* Mirror of `display_options` semantics in the Python NodeSpec. The
* golden-test suite locks the two implementations together.
*/
export function evaluateDisplayOptions(
rules: DisplayOptionsRule | null | undefined,
values: Record<string, unknown>,
): boolean {
if (!rules) return true;
if (rules.show) {
for (const [field, allowed] of Object.entries(rules.show)) {
const v = values[field];
if (!allowed?.some((a) => isEqual(a, v))) return false;
}
}
if (rules.hide) {
for (const [field, hidden] of Object.entries(rules.hide)) {
const v = values[field];
if (hidden?.some((h) => isEqual(h, v))) return false;
}
}
return true;
}
// Strict equality matches the Python `==` semantics used by the backend
// evaluator. Both implementations only compare scalar values
// (string|number|boolean|null) — anything richer is a spec authoring error.
function isEqual(a: unknown, b: unknown): boolean {
return a === b;
}

View file

@ -0,0 +1,4 @@
export { evaluateDisplayOptions } from "./displayOptions";
export { NodeEditForm, type NodeEditFormProps } from "./NodeEditForm";
export { PropertyInput, type PropertyInputProps, type RendererContext } from "./PropertyInput";
export { useNodeSpecs } from "./useNodeSpecs";

View file

@ -0,0 +1,62 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { listNodeTypesApiV1NodeTypesGet } from "@/client/sdk.gen";
import type { NodeSpec, NodeTypesResponse } from "@/client/types.gen";
import { useAuth } from "@/lib/auth";
interface State {
specs: NodeSpec[];
specVersion: string | null;
loading: boolean;
error: string | null;
}
let _cache: NodeTypesResponse | null = null;
/**
* Fetches the node-spec catalog once per session and caches it in module
* scope. Subsequent calls return the cached value synchronously.
*
* Spec changes require a backend restart and a page refresh adding a new
* node type while a session is active won't surface until reload.
*/
export function useNodeSpecs(): State & {
bySpecName: Map<string, NodeSpec>;
} {
const { user, loading: authLoading } = useAuth();
const hasFetched = useRef(false);
const [state, setState] = useState<State>(() => ({
specs: _cache?.node_types ?? [],
specVersion: _cache?.spec_version ?? null,
loading: !_cache,
error: null,
}));
useEffect(() => {
if (authLoading || !user || hasFetched.current || _cache) return;
hasFetched.current = true;
listNodeTypesApiV1NodeTypesGet({ throwOnError: true })
.then(({ data }) => {
_cache = data ?? null;
setState({
specs: data?.node_types ?? [],
specVersion: data?.spec_version ?? null,
loading: false,
error: null,
});
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setState((s) => ({ ...s, loading: false, error: message }));
});
}, [authLoading, user]);
const bySpecName = useMemo(() => {
return new Map(state.specs.map((s) => [s.name, s]));
}, [state.specs]);
return { ...state, bySpecName };
}

View file

@ -38,7 +38,9 @@ const initSentry = () => {
}
};
initSentry();
if (process.env.NEXT_PUBLIC_NODE_ENV !== 'development') {
initSentry();
}
// Initialize PostHog - prioritize NEXT_PUBLIC env vars, fallback to API
const initPostHog = () => {
@ -83,7 +85,9 @@ const initPostHog = () => {
}
};
initPostHog();
if (process.env.NEXT_PUBLIC_NODE_ENV !== 'development') {
initPostHog();
}
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

View file

@ -1,6 +1,10 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_PUBLIC_NODE_ENV === 'development') {
return;
}
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Enable source map support for better stack traces
if (process.env.NODE_ENV === 'development') {