mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
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:
parent
0a61ef295f
commit
00a1a22b74
162 changed files with 14355 additions and 3554 deletions
|
|
@ -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'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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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'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";
|
||||
|
||||
|
|
@ -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'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'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";
|
||||
485
ui/src/components/flow/nodes/GenericNode.tsx
Normal file
485
ui/src/components/flow/nodes/GenericNode.tsx
Normal 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";
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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'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";
|
||||
|
|
@ -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'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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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';
|
||||
48
ui/src/components/flow/renderer/NodeEditForm.tsx
Normal file
48
ui/src/components/flow/renderer/NodeEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
462
ui/src/components/flow/renderer/PropertyInput.tsx
Normal file
462
ui/src/components/flow/renderer/PropertyInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
ui/src/components/flow/renderer/displayOptions.ts
Normal file
50
ui/src/components/flow/renderer/displayOptions.ts
Normal 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;
|
||||
}
|
||||
4
ui/src/components/flow/renderer/index.ts
Normal file
4
ui/src/components/flow/renderer/index.ts
Normal 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";
|
||||
62
ui/src/components/flow/renderer/useNodeSpecs.ts
Normal file
62
ui/src/components/flow/renderer/useNodeSpecs.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue