diff options
Diffstat (limited to 'frontend/src/components/nodes')
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 264 |
1 files changed, 245 insertions, 19 deletions
diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx index cdd402c..8cbf0e9 100644 --- a/frontend/src/components/nodes/LLMNode.tsx +++ b/frontend/src/components/nodes/LLMNode.tsx @@ -1,18 +1,19 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Handle, Position, type NodeProps, useUpdateNodeInternals, useEdges } from 'reactflow'; -import type { NodeData } from '../../store/flowStore'; +import type { NodeData, MergedTrace, Trace } from '../../store/flowStore'; import { Loader2, MessageSquare } from 'lucide-react'; import useFlowStore from '../../store/flowStore'; const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { - const { updateNodeData } = useFlowStore(); + const { theme } = useFlowStore(); + const [showPreview, setShowPreview] = useState(false); const updateNodeInternals = useUpdateNodeInternals(); const edges = useEdges(); // Force update handles when traces change useEffect(() => { updateNodeInternals(id); - }, [id, data.outgoingTraces, data.inputs, updateNodeInternals]); + }, [id, data.outgoingTraces, data.mergedTraces, data.inputs, updateNodeInternals]); // Determine how many input handles to show // We want to ensure there is always at least one empty handle at the bottom @@ -49,55 +50,247 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const inputsToShow = Math.max(maxConnectedIndex + 2, 1); + const isDisabled = data.disabled; + const isDark = theme === 'dark'; + + // Truncate preview content + const previewContent = data.response + ? data.response.slice(0, 200) + (data.response.length > 200 ? '...' : '') + : data.userPrompt + ? data.userPrompt.slice(0, 100) + (data.userPrompt.length > 100 ? '...' : '') + : null; + return ( - <div className={`px-4 py-2 shadow-md rounded-md bg-white border-2 min-w-[200px] ${selected ? 'border-blue-500' : 'border-gray-200'}`}> + <div + className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all relative ${ + isDisabled + ? isDark + ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed' + : 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed' + : selected + ? isDark + ? 'bg-gray-800 border-blue-400' + : 'bg-white border-blue-500' + : isDark + ? 'bg-gray-800 border-gray-600' + : 'bg-white border-gray-200' + }`} + style={{ pointerEvents: isDisabled ? 'none' : 'auto' }} + onMouseEnter={() => setShowPreview(true)} + onMouseLeave={() => setShowPreview(false)} + > + {/* Content Preview Tooltip */} + {showPreview && previewContent && !isDisabled && ( + <div + className={`absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 w-64 p-3 rounded-lg shadow-xl text-xs whitespace-pre-wrap pointer-events-none ${ + isDark ? 'bg-gray-700 text-gray-200 border border-gray-600' : 'bg-white text-gray-700 border border-gray-200' + }`} + > + <div className={`font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + {data.response ? 'Response Preview' : 'Prompt Preview'} + </div> + {previewContent} + {/* Arrow */} + <div className={`absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent ${ + isDark ? 'border-t-gray-700' : 'border-t-white' + }`} /> + </div> + )} + <div className="flex items-center mb-2"> - <div className="rounded-full w-8 h-8 flex justify-center items-center bg-gray-100"> + <div className={`rounded-full w-8 h-8 flex justify-center items-center ${ + isDisabled + ? isDark ? 'bg-gray-700' : 'bg-gray-200' + : isDark ? 'bg-gray-700' : 'bg-gray-100' + }`}> {data.status === 'loading' ? ( <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> ) : ( - <MessageSquare className="w-4 h-4 text-gray-600" /> + <MessageSquare className={`w-4 h-4 ${ + isDisabled + ? 'text-gray-500' + : isDark ? 'text-gray-400' : 'text-gray-600' + }`} /> )} </div> <div className="ml-2"> - <div className="text-sm font-bold truncate max-w-[150px]">{data.label}</div> - <div className="text-xs text-gray-500">{data.model}</div> + <div className={`text-sm font-bold truncate max-w-[150px] ${ + isDisabled + ? 'text-gray-500' + : isDark ? 'text-gray-200' : 'text-gray-900' + }`}> + {data.label} + {isDisabled && <span className="text-xs ml-1">(disabled)</span>} + </div> + <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>{data.model}</div> </div> </div> {/* Dynamic Inputs */} <div className="absolute left-0 top-0 bottom-0 flex flex-col justify-center w-4"> + {/* Regular input handles */} {Array.from({ length: inputsToShow }).map((_, i) => { // Find the connected edge to get color const connectedEdge = edges.find(e => e.target === id && e.targetHandle === `input-${i}`); const edgeColor = connectedEdge?.style?.stroke as string; + // Check if this is a merged trace connection + const edgeData = (connectedEdge as any)?.data; + const isMergedTrace = edgeData?.isMerged; + const mergedColors = edgeData?.colors as string[] | undefined; + + // Create gradient for merged traces + let handleBackground: string = edgeColor || '#3b82f6'; + if (isMergedTrace && mergedColors && mergedColors.length >= 2) { + const gradientStops = mergedColors.map((color, idx) => + `${color} ${(idx / mergedColors.length) * 100}%, ${color} ${((idx + 1) / mergedColors.length) * 100}%` + ).join(', '); + handleBackground = `linear-gradient(45deg, ${gradientStops})`; + } + return ( <div key={i} className="relative h-4 w-4 my-1"> <Handle type="target" position={Position.Left} id={`input-${i}`} - className="!w-3 !h-3 !left-[-6px]" + className="!w-3 !h-3 !left-[-6px] !border-0" style={{ top: '50%', transform: 'translateY(-50%)', - backgroundColor: edgeColor || '#3b82f6', // Default blue if not connected - border: edgeColor ? 'none' : undefined + background: handleBackground }} /> - <span className="absolute left-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none"> + <span className={`absolute left-4 top-[-2px] text-[9px] pointer-events-none ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> {i} </span> </div> ); })} + + {/* Prepend input handles for traces that this node is the HEAD of */} + {/* Show dashed handle if no prepend connection yet (can accept prepend) */} + {/* Show solid handle if already has prepend connection (connected) */} + {data.outgoingTraces && data.outgoingTraces + .filter(trace => { + // Check if this is a self trace, fork trace originated from this node + const isSelfTrace = trace.id === `trace-${id}`; + // Strict check for fork trace: must be originated from this node + const isForkTrace = trace.id.startsWith('fork-') && trace.sourceNodeId === id; + + if (!isSelfTrace && !isForkTrace) return false; + + // Check if this trace has any outgoing edges (downstream connections) + const hasDownstream = edges.some(e => + e.source === id && e.sourceHandle === `trace-${trace.id}` + ); + return hasDownstream; + }) + .map((trace) => { + // Check if there's already a prepend connection to this trace + const hasPrependConnection = edges.some(e => + e.target === id && e.targetHandle === `prepend-${trace.id}` + ); + const prependEdge = edges.find(e => + e.target === id && e.targetHandle === `prepend-${trace.id}` + ); + + return ( + <div key={`prepend-${trace.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to: ${trace.id}`}> + <Handle + type="target" + position={Position.Left} + id={`prepend-${trace.id}`} + className="!w-3 !h-3 !left-[-6px] !border-2" + style={{ + top: '50%', + transform: 'translateY(-50%)', + backgroundColor: hasPrependConnection + ? (prependEdge?.style?.stroke as string || trace.color) + : trace.color, + borderColor: isDark ? '#374151' : '#fff', + borderStyle: hasPrependConnection ? 'solid' : 'dashed' + }} + /> + </div> + ); + }) + } + + {/* Prepend input handles for merged traces - REMOVED per user request */} + {/* Users should prepend to parent traces instead */} </div> {/* Dynamic Outputs (Traces) */} <div className="absolute right-0 top-0 bottom-0 flex flex-col justify-center w-4"> - {/* 1. Outgoing Traces (Pass-through + Self) */} - {data.outgoingTraces && data.outgoingTraces.map((trace, i) => ( + {/* 0. Incoming Trace Continue Handles - allow continuing an incoming trace */} + {/* Only show if there's NO downstream edge yet (dashed = waiting for connection) */} + {data.traces && data.traces + .filter((trace: Trace) => { + // Only show continue handle if NOT already connected downstream + // Now that trace IDs don't evolve, we check the base ID + const hasOutgoingEdge = edges.some(e => + e.source === id && + e.sourceHandle === `trace-${trace.id}` + ); + // Only show dashed handle if not yet connected + return !hasOutgoingEdge; + }) + .map((trace: Trace) => { + // Handle merged trace visualization + let backgroundStyle = trace.color; + if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) { + const colors = trace.mergedColors; + const gradientStops = colors.map((color: string, idx: number) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + backgroundStyle = `linear-gradient(45deg, ${gradientStops})`; + } + + return ( + <div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}> + <Handle + type="source" + position={Position.Right} + id={`trace-${trace.id}`} + className="!w-3 !h-3 !right-[-6px]" + style={{ + background: backgroundStyle, + top: '50%', + transform: 'translateY(-50%)', + border: `2px dashed ${isDark ? '#374151' : '#fff'}` + }} + /> + </div> + ); + })} + + {/* 1. Regular Outgoing Traces (Self + Forks + Connected Pass-through) */} + {/* Only show traces that have actual downstream edges */} + {data.outgoingTraces && data.outgoingTraces + .filter(trace => { + // Check if this is a locally created merged trace (should be shown in Part 2) + const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id); + if (isLocallyMerged) return false; + + // Only show if there's an actual downstream edge using this trace + const hasDownstream = edges.some(e => + e.source === id && e.sourceHandle === `trace-${trace.id}` + ); + return hasDownstream; + }) + .map((trace) => { + // Handle merged trace visualization + let backgroundStyle = trace.color; + if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) { + const colors = trace.mergedColors; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + backgroundStyle = `linear-gradient(45deg, ${gradientStops})`; + } + + return ( <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}> <Handle type="source" @@ -105,15 +298,48 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { id={`trace-${trace.id}`} className="!w-3 !h-3 !right-[-6px]" style={{ - backgroundColor: trace.color, + background: backgroundStyle, top: '50%', transform: 'translateY(-50%)' }} /> </div> - ))} + ); + })} + + {/* 2. Merged Trace Handles (with alternating color stripes) */} + {data.mergedTraces && data.mergedTraces.map((merged: MergedTrace) => { + // Check if this merged trace has any outgoing edges + const hasOutgoingEdge = edges.some(e => + e.source === id && e.sourceHandle === `trace-${merged.id}` + ); + + // Create a gradient background from the source trace colors + const colors = merged.colors.length > 0 ? merged.colors : ['#888']; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + const stripeGradient = `linear-gradient(45deg, ${gradientStops})`; + + return ( + <div key={merged.id} className="relative h-4 w-4 my-1" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}> + <Handle + type="source" + position={Position.Right} + id={`trace-${merged.id}`} + className="!w-3 !h-3 !right-[-6px]" + style={{ + background: stripeGradient, + top: '50%', + transform: 'translateY(-50%)', + border: hasOutgoingEdge ? 'none' : `2px dashed ${isDark ? '#374151' : '#fff'}` + }} + /> + </div> + ); + })} - {/* 2. New Branch Generator Handle (Always visible) */} + {/* 3. New Branch Generator Handle (Always visible) */} <div className="relative h-4 w-4 my-1" title="Create New Branch"> <Handle type="source" @@ -122,7 +348,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { className="!w-3 !h-3 !bg-gray-400 !right-[-6px]" style={{ top: '50%', transform: 'translateY(-50%)' }} /> - <span className="absolute right-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none w-max"> + <span className={`absolute right-4 top-[-2px] text-[9px] pointer-events-none w-max ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + New </span> </div> |
