Files
patchy631--ai-engineering-hub/open-agent-builder/components/app/(home)/sections/workflow-builder/CustomNodes.tsx
T
2026-07-13 12:37:47 +08:00

406 lines
12 KiB
TypeScript

"use client";
import * as React from "react";
import { Handle, Position } from "@xyflow/react";
import type { NodeProps } from "@xyflow/react";
import type { ReactNode } from "react";
// Custom node component with handles for connections
export function CustomNode({ data, selected }: NodeProps) {
const nodeType = data.nodeType;
const isRunning = data.isRunning;
const executionStatus = data.executionStatus;
// Note node state - MUST be declared before any conditional returns
// This ensures hooks are called in the same order every render
const noteText = String((data as any).noteText || 'Double-click to edit note');
const [isEditing, setIsEditing] = React.useState(false);
const [editText, setEditText] = React.useState<string>(noteText);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
// Determine border and background based on state
const getBorderStyle = () => {
// Note nodes have no border
if (nodeType === 'note') return 'none';
if (isRunning) return '1px solid #22C55E';
if (executionStatus === 'completed') return '1px solid #9ca3af';
if (executionStatus === 'failed') return '1px solid #eb3424';
if (selected) return '1px solid #22C55E';
return '1px solid #e5e7eb';
};
const getBackgroundColor = () => {
// Note nodes get yellow/gold background
if (nodeType === 'note') return '#ca8a04';
// All nodes have white background
return 'white';
};
const getOutlineStyle = () => {
if (isRunning) return '2px solid rgba(34, 197, 94, 0.32)';
if (selected) return '2px solid rgba(24, 24, 27, 0.18)';
return '2px solid transparent';
};
// Note nodes have different styling
const isNoteNode = nodeType === 'note';
// Determine text color based on background
const getTextColor = () => {
if (isNoteNode) return '#854d0e'; // Dark yellow text for note nodes
if (nodeType === 'if-else' || nodeType === 'while') {
return '#18181b'; // Dark text for orange background nodes
}
return '#18181b'; // Default dark text
};
// Update editText when noteText changes (for different notes)
React.useEffect(() => {
if (isNoteNode) {
setEditText(noteText);
}
}, [noteText, isNoteNode]);
// Note nodes are visual-only sticky notes with inline editing
if (isNoteNode) {
React.useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, [isEditing]);
const handleSave = () => {
setIsEditing(false);
// Update the node data
if ((data as any).onUpdate) {
(data as any).onUpdate({ noteText: editText });
}
};
return (
<div
className="relative"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
style={{
padding: '10px',
fontSize: '11px',
backgroundColor: '#fef9c3', // Light yellow
border: selected ? '2px solid #eab308' : 'none',
outline: selected ? '2px solid rgba(234, 179, 8, 0.2)' : 'none',
outlineOffset: 0,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.2s ease-out',
borderRadius: '6px',
minWidth: '140px',
maxWidth: '200px',
width: 'fit-content',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
color: '#854d0e',
lineHeight: '1.4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
cursor: isEditing ? 'text' : 'move',
}}
>
{/* Sticky note "tape" effect */}
<div
style={{
position: 'absolute',
top: '-6px',
left: '50%',
transform: 'translateX(-50%)',
width: '30px',
height: '12px',
backgroundColor: 'rgba(234, 179, 8, 0.3)',
borderRadius: '2px',
}}
/>
{isEditing ? (
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setEditText(noteText);
setIsEditing(false);
}
// Save on Ctrl/Cmd+Enter
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
handleSave();
}
// Don't propagate to prevent ReactFlow shortcuts
e.stopPropagation();
}}
className="nodrag"
style={{
width: '100%',
minHeight: '40px',
padding: '0',
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
fontSize: '11px',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
color: '#854d0e',
lineHeight: '1.4',
resize: 'vertical',
}}
/>
) : (
<div
style={{
minHeight: '20px',
cursor: 'move',
}}
>
{noteText || 'Double-click to edit note'}
</div>
)}
</div>
);
}
return (
<div
className="relative"
style={{
padding: '10px 16px',
fontSize: '13px',
backgroundColor: getBackgroundColor(),
border: getBorderStyle(),
outline: getOutlineStyle(),
outlineOffset: 0,
boxShadow: 'none',
transition: 'outline 0.2s ease-out, border 0.2s ease-out',
borderRadius: '16px',
minWidth: '140px',
maxWidth: '240px',
width: 'fit-content',
}}
>
{/* Input handle (left) - all nodes except 'start' and 'note' */}
{nodeType !== 'start' && nodeType !== 'note' && (
<Handle
type="target"
position={Position.Left}
id="input"
style={{
width: 10,
height: 10,
background: '#9ca3af',
border: '2px solid white',
left: -5,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
)}
{/* Render the label (icon + text) */}
<div style={{ whiteSpace: 'nowrap', color: getTextColor() }}>
{data.label as ReactNode}
</div>
{/* Output handles - special cases for branching nodes */}
{nodeType === 'if-else' ? (
<>
{/* If branch (left bottom) */}
<Handle
type="source"
position={Position.Right}
id="if"
style={{
width: 10,
height: 10,
background: '#FA5D19',
border: '2px solid white',
right: -5,
top: '35%',
transform: 'translateY(-50%)',
}}
/>
{/* Else branch (right bottom) */}
<Handle
type="source"
position={Position.Right}
id="else"
style={{
width: 10,
height: 10,
background: '#18181b',
border: '2px solid white',
right: -5,
top: '65%',
transform: 'translateY(-50%)',
}}
/>
{/* Branch labels */}
<div style={{
position: 'absolute',
top: '35%',
right: -50,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#FA5D19',
fontWeight: 600,
}}>If</div>
<div style={{
position: 'absolute',
top: '65%',
right: -55,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#18181b',
fontWeight: 600,
}}>Else</div>
</>
) : nodeType === 'user-approval' ? (
<>
{/* Approve branch (left bottom) */}
<Handle
type="source"
position={Position.Right}
id="approve"
style={{
width: 10,
height: 10,
background: '#10b981',
border: '2px solid white',
right: -5,
top: '35%',
transform: 'translateY(-50%)',
}}
/>
{/* Reject branch (right bottom) */}
<Handle
type="source"
position={Position.Right}
id="reject"
style={{
width: 10,
height: 10,
background: '#ef4444',
border: '2px solid white',
right: -5,
top: '65%',
transform: 'translateY(-50%)',
}}
/>
{/* Branch labels */}
<div style={{
position: 'absolute',
top: '35%',
right: -70,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#10b981',
fontWeight: 600,
}}>Approve</div>
<div style={{
position: 'absolute',
top: '65%',
right: -60,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#ef4444',
fontWeight: 600,
}}>Reject</div>
</>
) : nodeType === 'while' ? (
<>
{/* Continue branch (top) */}
<Handle
type="source"
position={Position.Right}
id="continue"
style={{
width: 10,
height: 10,
background: '#FA5D19',
border: '2px solid white',
right: -5,
top: '35%',
transform: 'translateY(-50%)',
}}
/>
{/* Break branch (bottom) */}
<Handle
type="source"
position={Position.Right}
id="break"
style={{
width: 10,
height: 10,
background: '#18181b',
border: '2px solid white',
right: -5,
top: '65%',
transform: 'translateY(-50%)',
}}
/>
{/* Branch labels */}
<div style={{
position: 'absolute',
top: '35%',
right: -70,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#FA5D19',
fontWeight: 600,
}}>Continue</div>
<div style={{
position: 'absolute',
top: '65%',
right: -55,
transform: 'translateY(-50%)',
fontSize: '10px',
color: '#18181b',
fontWeight: 600,
}}>Break</div>
</>
) : nodeType !== 'end' && nodeType !== 'note' ? (
<Handle
type="source"
position={Position.Right}
id="output"
style={{
width: 10,
height: 10,
background: '#9ca3af',
border: '2px solid white',
right: -5,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
) : null}
</div>
);
}
// Create all node types by reusing the same component
export const nodeTypes = {
start: CustomNode,
agent: CustomNode,
mcp: CustomNode,
extract: CustomNode,
end: CustomNode,
note: CustomNode,
logic: CustomNode,
data: CustomNode,
http: CustomNode,
transform: CustomNode,
'if-else': CustomNode,
'while': CustomNode,
'user-approval': CustomNode,
'set-state': CustomNode,
};