diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 05:07:46 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 05:07:46 +0000 |
| commit | b6f21c210ee804782eba2e7c30c2ccdcbd95bffb (patch) | |
| tree | 4ff355d72a511063ba366c5052300cf1ca6f60a6 | |
| parent | 7d897ad9bb5ee46839ec91992cbbf4593168f119 (diff) | |
Add unfold merged trace: convert to sequential node chain
Unfold takes a merged trace's messages, extracts the node order,
and creates real edges chaining those nodes sequentially (A→B→C→D→E).
The merged trace is deleted and replaced by a regular pass-through trace.
- Add unfoldMergedTrace() to flowStore (creates edges, rewires downstream)
- Add Unfold button (Layers icon) to Sidebar merged traces UI
- Fix isMerged edge detection to use explicit flag instead of ID prefix
- Fix LLMNode useUpdateNodeInternals deps for dynamic handle updates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 98 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 77 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 510 |
3 files changed, 501 insertions, 184 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 78d2475..65d5cd2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,7 +6,7 @@ import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../st import type { Edge } from 'reactflow'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react'; +import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -18,7 +18,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const { nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode, isTraceComplete, theme, - createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages, + createMergedTrace, updateMergedTrace, deleteMergedTrace, unfoldMergedTrace, computeMergedMessages, files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath, saveCurrentBlueprint } = useFlowStore(); @@ -183,6 +183,39 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { } }; + // Image helpers + const isImageFile = (mime: string) => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime); + const getImageUrl = (fileId: string) => `${import.meta.env.VITE_BACKEND_URL || ''}/api/files/download?user=${encodeURIComponent(user?.username || 'test')}&file_id=${encodeURIComponent(fileId)}`; + + // Paste handler: upload pasted image and attach it + const handlePasteImage = async ( + e: React.ClipboardEvent<HTMLTextAreaElement>, + addFile: (fileId: string) => void, + scopeFn?: () => string, + ) => { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith('image/')) { + e.preventDefault(); + const blob = item.getAsFile(); + if (!blob) continue; + const file = new File([blob], `paste-${Date.now()}.${blob.type.split('/')[1] || 'png'}`, { type: blob.type }); + try { + const meta = await uploadFile(file, { provider: 'local' }); + addFile(meta.id); + if (scopeFn) { + try { await addFileScope(meta.id, scopeFn()); } catch {} + } + } catch (err) { + console.error('Paste upload failed:', err); + } + return; // only handle first image + } + } + }; + // Filter files for attach modal const filteredFilesToAttach = useMemo(() => { const q = attachSearch.trim().toLowerCase(); @@ -1036,6 +1069,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { }) }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || `HTTP ${response.status}`); + } if (!response.body) throw new Error('No response body'); const reader = response.body.getReader(); @@ -1671,7 +1708,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { {/* Alternating color indicator */} <div className="flex -space-x-1 shrink-0"> {merged.colors.slice(0, 3).map((color, idx) => ( - <div + <div key={idx} className="w-3 h-3 rounded-full border-2" style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }} @@ -1685,7 +1722,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { </div> )} </div> - + <div className="flex-1 min-w-0"> <div className={`flex items-center gap-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}> <span className="font-mono truncate">Merged #{merged.id.slice(-6)}</span> @@ -1718,6 +1755,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <button onClick={(e) => { e.stopPropagation(); + unfoldMergedTrace(selectedNode.id, merged.id); + }} + className={`p-1 rounded shrink-0 ${ + isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-50 text-gray-400 hover:text-blue-600' + }`} + title="Unfold to regular trace" + > + <Layers size={12} /> + </button> + + <button + onClick={(e) => { + e.stopPropagation(); deleteMergedTrace(selectedNode.id, merged.id); }} className={`p-1 rounded shrink-0 ${ @@ -1736,9 +1786,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <div> <label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label> - <textarea + <textarea value={selectedNode.data.userPrompt} onChange={(e) => handleChange('userPrompt', e.target.value)} + onPaste={(e) => handlePasteImage( + e, + (id) => { + const current = selectedNode.data.attachedFileIds || []; + if (!current.includes(id)) { + updateNodeData(selectedNode.id, { attachedFileIds: [...current, id] }); + } + }, + () => `${currentBlueprintPath || 'untitled'}/${selectedNode.id}`, + )} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -1924,15 +1984,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { {(selectedNode.data.attachedFileIds || []).map(id => { const file = files.find(f => f.id === id); if (!file) return null; + const isImg = isImageFile(file.mime); return ( - <div - key={id} + <div + key={id} className={`group flex items-center justify-between p-2 rounded text-xs ${ isDark ? 'bg-gray-700/50' : 'bg-white border border-gray-200' }`} > <div className="flex items-center gap-2 overflow-hidden"> - <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} /> + {isImg ? ( + <img src={getImageUrl(id)} alt={file.name} className="w-8 h-8 object-cover rounded" /> + ) : ( + <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} /> + )} <span className={`truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}> {file.name} </span> @@ -2497,7 +2562,12 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { <div className="flex flex-wrap gap-2"> {sentFilesForMsg.map(fileId => { const file = files.find(f => f.id === fileId); - return ( + const isImg = file && isImageFile(file.mime); + return isImg ? ( + <a key={fileId} href={getImageUrl(fileId)} target="_blank" rel="noopener noreferrer" title={file?.name}> + <img src={getImageUrl(fileId)} alt={file?.name || 'Image'} className="max-w-[200px] max-h-[150px] rounded object-cover" /> + </a> + ) : ( <div key={fileId} className="flex items-center gap-1 bg-blue-600 rounded px-2 py-1 text-xs"> <FileText size={12} /> <span className="max-w-[120px] truncate">{file?.name || 'File'}</span> @@ -2671,14 +2741,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { {quickChatAttachedFiles.map(fileId => { const file = files.find(f => f.id === fileId); if (!file) return null; + const isImg = isImageFile(file.mime); return ( - <div + <div key={fileId} className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${ isDark ? 'bg-gray-600 text-gray-200' : 'bg-white text-gray-700 border border-gray-300' }`} > - <FileText size={12} /> + {isImg ? ( + <img src={getImageUrl(fileId)} alt={file.name} className="w-8 h-8 object-cover rounded" /> + ) : ( + <FileText size={12} /> + )} <span className="max-w-[120px] truncate">{file.name}</span> <button onClick={() => handleQuickChatDetach(fileId)} @@ -2697,6 +2772,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { ref={quickChatInputRef} value={quickChatInput} onChange={(e) => setQuickChatInput(e.target.value)} + onPaste={(e) => handlePasteImage(e, (id) => setQuickChatAttachedFiles(prev => [...prev, id]), getQuickChatScope)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx index 8cbf0e9..7542860 100644 --- a/frontend/src/components/nodes/LLMNode.tsx +++ b/frontend/src/components/nodes/LLMNode.tsx @@ -10,10 +10,10 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const updateNodeInternals = useUpdateNodeInternals(); const edges = useEdges(); - // Force update handles when traces change + // Force update handles when traces or edges change useEffect(() => { updateNodeInternals(id); - }, [id, data.outgoingTraces, data.mergedTraces, data.inputs, updateNodeInternals]); + }, [id, data.traces, data.outgoingTraces, data.mergedTraces, data.forkedTraces, data.inputs, edges.length, updateNodeInternals]); // Determine how many input handles to show // We want to ensure there is always at least one empty handle at the bottom @@ -53,6 +53,35 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const isDisabled = data.disabled; const isDark = theme === 'dark'; + // Calculate handle counts to determine minimum node height + // Each handle slot is 16px (h-3=12px + my-0.5*2=4px) + const HANDLE_SLOT = 16; + const NODE_PADDING = 16; // py-2 top + bottom + + // Left side: regular inputs + prepend handles + const prependCount = (data.outgoingTraces || []).filter(trace => { + const isSelfTrace = trace.id === `trace-${id}`; + const isForkTrace = trace.id.startsWith('fork-') && trace.sourceNodeId === id; + if (!isSelfTrace && !isForkTrace) return false; + return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`); + }).length; + const leftHandleCount = inputsToShow + prependCount; + + // Right side: continue + outgoing + merged + new branch + const continueCount = (data.traces || []).filter((trace: any) => { + return !edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`); + }).length; + const outgoingCount = (data.outgoingTraces || []).filter(trace => { + const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id); + if (isLocallyMerged) return false; + return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`); + }).length; + const mergedCount = (data.mergedTraces || []).length; + const rightHandleCount = continueCount + outgoingCount + mergedCount + 1; // +1 for "new branch" + + const maxHandles = Math.max(leftHandleCount, rightHandleCount); + const minHandleHeight = maxHandles * HANDLE_SLOT + NODE_PADDING; + // Truncate preview content const previewContent = data.response ? data.response.slice(0, 200) + (data.response.length > 200 ? '...' : '') @@ -61,13 +90,13 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { : null; return ( - <div + <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' + 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 + : selected ? isDark ? 'bg-gray-800 border-blue-400' : 'bg-white border-blue-500' @@ -75,7 +104,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200' }`} - style={{ pointerEvents: isDisabled ? 'none' : 'auto' }} + style={{ pointerEvents: isDisabled ? 'none' : 'auto', minHeight: minHandleHeight }} onMouseEnter={() => setShowPreview(true)} onMouseLeave={() => setShowPreview(false)} > @@ -145,16 +174,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const gradientStops = mergedColors.map((color, idx) => `${color} ${(idx / mergedColors.length) * 100}%, ${color} ${((idx + 1) / mergedColors.length) * 100}%` ).join(', '); - handleBackground = `linear-gradient(45deg, ${gradientStops})`; + handleBackground = `conic-gradient(from 0deg, ${gradientStops})`; } return ( - <div key={i} className="relative h-4 w-4 my-1"> + <div key={i} className="relative h-3 w-3 my-0.5"> <Handle type="target" position={Position.Left} id={`input-${i}`} - className="!w-3 !h-3 !left-[-6px] !border-0" + className="!w-2.5 !h-2.5 !left-[-6px] !border-0" style={{ top: '50%', transform: 'translateY(-50%)', @@ -196,12 +225,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { ); return ( - <div key={`prepend-${trace.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to: ${trace.id}`}> + <div key={`prepend-${trace.id}`} className="relative h-3 w-3 my-0.5" 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" + className="!w-2.5 !h-2.5 !left-[-6px] !border-2" style={{ top: '50%', transform: 'translateY(-50%)', @@ -244,16 +273,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { 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})`; + backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`; } return ( - <div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}> + <div key={`continue-${trace.id}`} className="relative h-3 w-3 my-0.5" title={`Continue trace: ${trace.id}`}> <Handle type="source" position={Position.Right} id={`trace-${trace.id}`} - className="!w-3 !h-3 !right-[-6px]" + className="!w-2.5 !h-2.5 !right-[-6px]" style={{ background: backgroundStyle, top: '50%', @@ -287,16 +316,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const gradientStops = colors.map((color, idx) => `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` ).join(', '); - backgroundStyle = `linear-gradient(45deg, ${gradientStops})`; + backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`; } return ( - <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}> + <div key={trace.id} className="relative h-3 w-3 my-0.5" title={`Trace: ${trace.id}`}> <Handle type="source" position={Position.Right} id={`trace-${trace.id}`} - className="!w-3 !h-3 !right-[-6px]" + className="!w-2.5 !h-2.5 !right-[-6px]" style={{ background: backgroundStyle, top: '50%', @@ -319,15 +348,15 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const gradientStops = colors.map((color, idx) => `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` ).join(', '); - const stripeGradient = `linear-gradient(45deg, ${gradientStops})`; + const stripeGradient = `conic-gradient(from 0deg, ${gradientStops})`; return ( - <div key={merged.id} className="relative h-4 w-4 my-1" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}> + <div key={merged.id} className="relative h-3 w-3 my-0.5" 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]" + className="!w-2.5 !h-2.5 !right-[-6px]" style={{ background: stripeGradient, top: '50%', @@ -340,12 +369,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { })} {/* 3. New Branch Generator Handle (Always visible) */} - <div className="relative h-4 w-4 my-1" title="Create New Branch"> + <div className="relative h-3 w-3 my-0.5" title="Create New Branch"> <Handle type="source" position={Position.Right} id="new-trace" - className="!w-3 !h-3 !bg-gray-400 !right-[-6px]" + className="!w-2.5 !h-2.5 !bg-gray-400 !right-[-6px]" style={{ top: '50%', transform: 'translateY(-50%)' }} /> <span className={`absolute right-4 top-[-2px] text-[9px] pointer-events-none w-max ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts index e2cd5d1..72298b8 100644 --- a/frontend/src/store/flowStore.ts +++ b/frontend/src/store/flowStore.ts @@ -231,6 +231,7 @@ interface FlowState { updates: { sourceTraceIds?: string[]; strategy?: MergeStrategy; summarizedContent?: string } ) => void; deleteMergedTrace: (nodeId: string, mergedTraceId: string) => void; + unfoldMergedTrace: (nodeId: string, mergedTraceId: string) => void; computeMergedMessages: ( nodeId: string, sourceTraceIds: string[], @@ -332,98 +333,145 @@ const useFlowStore = create<FlowState>((set, get) => { set({ uploadingFileIds: ids }); }, findNonOverlappingPosition: (baseX: number, baseY: number) => { - const { nodes } = get(); - // Estimate larger dimensions to be safe, considering dynamic handles - const nodeWidth = 300; - const nodeHeight = 200; + const { nodes, edges } = get(); + const nodeWidth = 300; + const newNodeHeight = 80; // new node starts small const padding = 20; - + + // Compute height for existing nodes based on handle count + const HANDLE_SLOT = 16; + const existingHeight = (node: LLMNode) => { + let maxConnIdx = -1; + edges.filter(e => e.target === node.id).forEach(e => { + const idx = parseInt(e.targetHandle?.replace('input-', '') || '0'); + if (!isNaN(idx) && idx > maxConnIdx) maxConnIdx = idx; + }); + const leftCount = Math.max(maxConnIdx + 2, 1); + const rightCount = ((node.data.outgoingTraces || []).length || 0) + (node.data.mergedTraces || []).length + 1; + return Math.max(Math.max(leftCount, rightCount) * HANDLE_SLOT + 16, 60); + }; + let x = baseX; let y = baseY; let attempts = 0; - const maxAttempts = 100; // Increase attempts - + const maxAttempts = 100; + const isOverlapping = (testX: number, testY: number) => { return nodes.some(node => { const nodeX = node.position.x; const nodeY = node.position.y; - return !(testX + nodeWidth + padding < nodeX || + const nh = existingHeight(node); + return !(testX + nodeWidth + padding < nodeX || testX > nodeX + nodeWidth + padding || - testY + nodeHeight + padding < nodeY || - testY > nodeY + nodeHeight + padding); + testY + newNodeHeight + padding < nodeY || + testY > nodeY + nh + padding); }); }; - - // Try positions in a spiral pattern + while (isOverlapping(x, y) && attempts < maxAttempts) { attempts++; - // Spiral parameters - const angle = attempts * 0.5; // Slower rotation - const radius = 50 + attempts * 30; // Faster expansion - + const angle = attempts * 0.5; + const radius = 50 + attempts * 30; x = baseX + Math.cos(angle) * radius; y = baseY + Math.sin(angle) * radius; } - + return { x, y }; }, autoLayout: () => { const { nodes, edges } = get(); if (nodes.length === 0) return; - + + // Compute dynamic height per node based on handle count + const HANDLE_SLOT = 16; + const NODE_BASE_HEIGHT = 60; // minimum content height + const NODE_PADDING = 16; + const getNodeHeight = (node: LLMNode) => { + // Left side: input handles + prepend handles + let maxConnIdx = -1; + edges.filter(e => e.target === node.id).forEach(e => { + const idx = parseInt(e.targetHandle?.replace('input-', '') || '0'); + if (!isNaN(idx) && idx > maxConnIdx) maxConnIdx = idx; + }); + const inputCount = Math.max(maxConnIdx + 2, 1); + const prependCount = (node.data.outgoingTraces || []).filter(t => { + const isSelf = t.id === `trace-${node.id}`; + const isFork = t.id.startsWith('fork-') && t.sourceNodeId === node.id; + if (!isSelf && !isFork) return false; + return edges.some(e => e.source === node.id && e.sourceHandle === `trace-${t.id}`); + }).length; + const leftCount = inputCount + prependCount; + + // Right side: continue + outgoing + merged + new branch + const continueCount = (node.data.traces || []).filter((t: any) => + !edges.some(e => e.source === node.id && e.sourceHandle === `trace-${t.id}`) + ).length; + const outgoingCount = (node.data.outgoingTraces || []).filter(t => { + if (node.data.mergedTraces?.some(m => m.id === t.id)) return false; + return edges.some(e => e.source === node.id && e.sourceHandle === `trace-${t.id}`); + }).length; + const mergedCount = (node.data.mergedTraces || []).length; + const rightCount = continueCount + outgoingCount + mergedCount + 1; + + const handleHeight = Math.max(leftCount, rightCount) * HANDLE_SLOT + NODE_PADDING; + return Math.max(handleHeight, NODE_BASE_HEIGHT); + }; + // Find root nodes (no incoming edges) const nodesWithIncoming = new Set(edges.map(e => e.target)); const rootNodes = nodes.filter(n => !nodesWithIncoming.has(n.id)); - - // BFS to layout nodes in levels - const nodePositions: Map<string, { x: number; y: number }> = new Map(); + + // BFS to assign levels first + const nodeLevels: Map<string, number> = new Map(); const visited = new Set<string>(); - const queue: { id: string; level: number; index: number }[] = []; - + const bfsQueue: { id: string; level: number }[] = []; + const horizontalSpacing = 350; - const verticalSpacing = 150; - - // Initialize with root nodes - rootNodes.forEach((node, index) => { - queue.push({ id: node.id, level: 0, index }); + const verticalGap = 30; // gap between nodes + + rootNodes.forEach((node) => { + bfsQueue.push({ id: node.id, level: 0 }); visited.add(node.id); }); - - // Track nodes per level for vertical positioning - const nodesPerLevel: Map<number, number> = new Map(); - - while (queue.length > 0) { - const { id, level } = queue.shift()!; - - // Count nodes at this level - const currentCount = nodesPerLevel.get(level) || 0; - nodesPerLevel.set(level, currentCount + 1); - - // Calculate position - const x = 100 + level * horizontalSpacing; - const y = 100 + currentCount * verticalSpacing; - nodePositions.set(id, { x, y }); - - // Find child nodes + + while (bfsQueue.length > 0) { + const { id, level } = bfsQueue.shift()!; + nodeLevels.set(id, level); + const outgoingEdges = edges.filter(e => e.source === id); - outgoingEdges.forEach((edge, i) => { + outgoingEdges.forEach((edge) => { if (!visited.has(edge.target)) { visited.add(edge.target); - queue.push({ id: edge.target, level: level + 1, index: i }); + bfsQueue.push({ id: edge.target, level: level + 1 }); } }); } - - // Handle orphan nodes (not connected to anything) - let orphanY = 100; + + // Handle orphan nodes nodes.forEach(node => { - if (!nodePositions.has(node.id)) { - nodePositions.set(node.id, { x: 100, y: orphanY }); - orphanY += verticalSpacing; - } + if (!nodeLevels.has(node.id)) nodeLevels.set(node.id, 0); }); - + + // Group nodes by level + const levelNodes: Map<number, LLMNode[]> = new Map(); + nodes.forEach(node => { + const level = nodeLevels.get(node.id) || 0; + if (!levelNodes.has(level)) levelNodes.set(level, []); + levelNodes.get(level)!.push(node); + }); + + // Position nodes per level with dynamic Y based on cumulative heights + const nodePositions: Map<string, { x: number; y: number }> = new Map(); + levelNodes.forEach((levelNodeList, level) => { + const x = 100 + level * horizontalSpacing; + let y = 100; + levelNodeList.forEach(node => { + nodePositions.set(node.id, { x, y }); + y += getNodeHeight(node) + verticalGap; + }); + }); + // Apply positions set({ nodes: nodes.map(node => ({ @@ -538,64 +586,89 @@ const useFlowStore = create<FlowState>((set, get) => { // Helper to trace back the path of a trace by following edges upstream const duplicateTracePath = ( - traceId: string, + traceId: string, forkAtNodeId: string, traceOwnerNodeId?: string, pendingEdges: Edge[] = [] ): { newTraceId: string, newEdges: Edge[], firstNodeId: string } | null => { - // Trace back from forkAtNodeId to find the origin of this trace - // We follow incoming edges that match the trace pattern - - const pathNodes: string[] = [forkAtNodeId]; + // Trace back from forkAtNodeId to find the origin of this trace. + // Handles merge boundaries: when a merged trace is encountered, expand + // into its sourceTraceIds and follow each branch backward. + + // Collect all edges in the upstream path (may be a tree, not just a line) const pathEdges: Edge[] = []; - let currentNodeId = forkAtNodeId; - - // Trace backwards through incoming edges - while (true) { - // Find incoming edge to current node that carries THIS trace ID - const incomingEdge = edges.find(e => - e.target === currentNodeId && - e.sourceHandle === `trace-${traceId}` + const visitedNodes = new Set<string>(); + const visitedEdges = new Set<string>(); + + const traceBackward = (nodeId: string, tid: string) => { + if (visitedNodes.has(`${nodeId}:${tid}`)) return; + visitedNodes.add(`${nodeId}:${tid}`); + + // At a merge boundary: if this node owns a merged trace matching tid, + // expand into source traces and continue backward on each. + const node = nodes.find(n => n.id === nodeId); + if (node) { + const mergedDef = (node.data.mergedTraces || []).find((m: any) => m.id === tid); + if (mergedDef) { + for (const srcId of mergedDef.sourceTraceIds) { + // Follow each source trace into this node + const srcEdge = edges.find(e => + e.target === nodeId && e.sourceHandle === `trace-${srcId}` + ); + if (srcEdge && !visitedEdges.has(srcEdge.id)) { + visitedEdges.add(srcEdge.id); + pathEdges.push(srcEdge); + traceBackward(srcEdge.source, srcId); + } + } + return; // Don't also look for an edge carrying the merged ID into this node + } + } + + // Normal case: find the incoming edge carrying this trace ID + const incomingEdge = edges.find(e => + e.target === nodeId && e.sourceHandle === `trace-${tid}` ); - - if (!incomingEdge) break; // Reached the start of the trace - - pathNodes.unshift(incomingEdge.source); - pathEdges.unshift(incomingEdge); - currentNodeId = incomingEdge.source; - } - - // If path only has one node, no upstream to duplicate - if (pathNodes.length <= 1) return null; - - const firstNodeId = pathNodes[0]; + if (!incomingEdge || visitedEdges.has(incomingEdge.id)) return; + visitedEdges.add(incomingEdge.id); + pathEdges.push(incomingEdge); + traceBackward(incomingEdge.source, tid); + }; + + traceBackward(forkAtNodeId, traceId); + + // If no upstream edges found, nothing to duplicate + if (pathEdges.length === 0) return null; + + // Find the root nodes (sources that never appear as targets in pathEdges) + const targetSet = new Set(pathEdges.map(e => e.target)); + const sourceSet = new Set(pathEdges.map(e => e.source)); + const rootNodes = [...sourceSet].filter(s => !targetSet.has(s)); + // Use the first root as the "firstNode" for the fork trace definition + const firstNodeId = rootNodes[0] || pathEdges[0].source; const firstNode = nodes.find(n => n.id === firstNodeId); if (!firstNode) return null; - + // Create a new trace ID for the duplicated path (guarantee uniqueness even within the same ms) const uniq = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const newTraceId = `fork-${firstNodeId}-${uniq}`; const newTraceColor = getStableColor(newTraceId); - - // Create new edges for the entire path + + // Create new edges mirroring each original path edge const newEdges: Edge[] = []; - - // Track which input handles we're creating for new edges const newInputHandles: Map<string, number> = new Map(); - + for (let i = 0; i < pathEdges.length; i++) { - const fromNodeId = pathNodes[i]; - const toNodeId = pathNodes[i + 1]; - - // Find the next available input handle for the target node - // Count existing edges to this node + any new edges we're creating + const fromNodeId = pathEdges[i].source; + const toNodeId = pathEdges[i].target; + const existingEdgesToTarget = edges.filter(e => e.target === toNodeId).length + pendingEdges.filter(e => e.target === toNodeId).length; const newEdgesToTarget = newInputHandles.get(toNodeId) || 0; const nextInputIndex = existingEdgesToTarget + newEdgesToTarget; newInputHandles.set(toNodeId, newEdgesToTarget + 1); - + newEdges.push({ id: `edge-fork-${uniq}-${i}`, source: fromNodeId, @@ -806,7 +879,7 @@ const useFlowStore = create<FlowState>((set, get) => { } } - const newForkId = `fork-${sourceNode.id}-${Date.now()}`; + const newForkId = `fork-${sourceNode.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const newForkTrace: Trace = { id: newForkId, sourceNodeId: sourceNode.id, @@ -1029,23 +1102,19 @@ const useFlowStore = create<FlowState>((set, get) => { const edgesToDelete = new Set<string>(); // Helper to traverse downstream EDGES based on Trace Dependency + // Uses pass-through model: trace ID stays the same across nodes const traverse = (currentEdge: Edge) => { if (edgesToDelete.has(currentEdge.id)) return; edgesToDelete.add(currentEdge.id); - + const targetNodeId = currentEdge.target; - // Identify the trace ID carried by this edge const traceId = currentEdge.sourceHandle?.replace('trace-', ''); - if (!traceId) return; - - // Look for outgoing edges from the target node that carry the EVOLUTION of this trace. - // Our logic generates next trace ID as: `${traceId}_${targetNodeId}` - const expectedNextTraceId = `${traceId}_${targetNodeId}`; - + if (!traceId) return; + + // Pass-through model: downstream edges carry the same trace ID const outgoing = edges.filter(e => e.source === targetNodeId); outgoing.forEach(nextEdge => { - // If the outgoing edge carries the evolved trace, delete it too - if (nextEdge.sourceHandle === `trace-${expectedNextTraceId}`) { + if (nextEdge.sourceHandle === `trace-${traceId}`) { traverse(nextEdge); } }); @@ -1072,12 +1141,13 @@ const useFlowStore = create<FlowState>((set, get) => { if (startEdge) { traverse(startEdge); } - + set({ edges: edges.filter(e => !edgesToDelete.has(e.id)) }); } + console.log(`[deleteBranch] Deleted ${edgesToDelete.size} edges`); get().propagateTraces(); }, @@ -1088,51 +1158,45 @@ const useFlowStore = create<FlowState>((set, get) => { const nodesInTrace = new Set<string>(); // Helper to traverse downstream EDGES based on Trace Dependency + // Uses pass-through model: trace ID stays the same across nodes const traverse = (currentEdge: Edge) => { if (edgesToDelete.has(currentEdge.id)) return; edgesToDelete.add(currentEdge.id); - + const targetNodeId = currentEdge.target; nodesInTrace.add(targetNodeId); - - // Identify the trace ID carried by this edge + const traceId = currentEdge.sourceHandle?.replace('trace-', ''); - if (!traceId) return; - - // Look for outgoing edges from the target node that carry the EVOLUTION of this trace. - const expectedNextTraceId = `${traceId}_${targetNodeId}`; - + if (!traceId) return; + + // Pass-through model: downstream edges carry the same trace ID const outgoing = edges.filter(e => e.source === targetNodeId); outgoing.forEach(nextEdge => { - if (nextEdge.sourceHandle === `trace-${expectedNextTraceId}`) { + if (nextEdge.sourceHandle === `trace-${traceId}`) { traverse(nextEdge); } }); }; // Also traverse backwards to find upstream nodes + // Uses pass-through model: trace ID stays the same across nodes const traverseBackward = (currentEdge: Edge) => { if (edgesToDelete.has(currentEdge.id)) return; edgesToDelete.add(currentEdge.id); - + const sourceNodeId = currentEdge.source; nodesInTrace.add(sourceNodeId); - - // Find the incoming edge to the source node that is part of the same trace + const traceId = currentEdge.sourceHandle?.replace('trace-', ''); if (!traceId) return; - - // Find the parent trace ID by removing the last _nodeId suffix - const lastUnderscore = traceId.lastIndexOf('_'); - if (lastUnderscore > 0) { - const parentTraceId = traceId.substring(0, lastUnderscore); - const incoming = edges.filter(e => e.target === sourceNodeId); - incoming.forEach(prevEdge => { - if (prevEdge.sourceHandle === `trace-${parentTraceId}`) { - traverseBackward(prevEdge); - } - }); - } + + // Pass-through model: upstream edges carry the same trace ID + const incoming = edges.filter(e => e.target === sourceNodeId); + incoming.forEach(prevEdge => { + if (prevEdge.sourceHandle === `trace-${traceId}`) { + traverseBackward(prevEdge); + } + }); }; const startEdge = edges.find(e => e.id === startEdgeId); @@ -1157,6 +1221,8 @@ const useFlowStore = create<FlowState>((set, get) => { } }); + console.log(`[deleteTrace] Deleted ${edgesToDelete.size} edges, ${nodesToDelete.size} orphaned nodes`); + set({ nodes: nodes.filter(n => !nodesToDelete.has(n.id)), edges: remainingEdges @@ -1491,9 +1557,38 @@ const useFlowStore = create<FlowState>((set, get) => { }, serializeBlueprint: (viewport?: ViewportState): BlueprintDocument => { + // Strip computed/redundant fields from nodes to reduce file size. + // traces, outgoingTraces, and messages are recomputed by propagateTraces() on load. + // mergedTraces/forkedTraces only need their definitions, not the computed messages. + const leanNodes = get().nodes.map(n => ({ + ...n, + data: { + ...n.data, + // Drop fully-computed fields (rebuilt by propagateTraces) + traces: undefined, + outgoingTraces: undefined, + messages: undefined, + // Keep merged/forked trace definitions but strip their computed messages + mergedTraces: (n.data.mergedTraces || []).map((m: any) => ({ + id: m.id, + sourceNodeId: m.sourceNodeId, + sourceTraceIds: m.sourceTraceIds, + strategy: m.strategy, + colors: m.colors, + // messages omitted — recomputed by propagateTraces + })), + forkedTraces: (n.data.forkedTraces || []).map((f: any) => ({ + id: f.id, + sourceNodeId: f.sourceNodeId, + color: f.color, + // messages omitted — recomputed by propagateTraces + })), + } + })); + return { version: 1, - nodes: get().nodes, + nodes: leanNodes as any, edges: get().edges, viewport: viewport || get().lastViewport, theme: get().theme, @@ -1515,17 +1610,46 @@ const useFlowStore = create<FlowState>((set, get) => { saveBlueprintFile: async (path: string, viewport?: ViewportState) => { const payload = get().serializeBlueprint(viewport); - await jsonFetch(`${API_BASE}/api/projects/save_blueprint`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + let body: string; + try { + body = JSON.stringify({ user: getCurrentUser(), path, content: payload, - }), + }); + } catch (stringifyErr: any) { + // Diagnose which node causes the circular reference + console.error('[saveBlueprintFile] JSON.stringify FAILED:', stringifyErr?.message); + for (const node of payload.nodes) { + try { + JSON.stringify(node); + } catch { + console.error('[saveBlueprintFile] Problematic node:', node.id, + 'traces:', node.data?.traces?.length, + 'outgoing:', node.data?.outgoingTraces?.length, + 'merged:', node.data?.mergedTraces?.length); + // Try individual traces + for (const t of (node.data?.outgoingTraces || [])) { + try { JSON.stringify(t); } catch { console.error('[saveBlueprintFile] Bad outgoing trace:', t.id); } + } + for (const t of (node.data?.traces || [])) { + try { JSON.stringify(t); } catch { console.error('[saveBlueprintFile] Bad incoming trace:', t.id); } + } + } + } + throw stringifyErr; + } + await jsonFetch(`${API_BASE}/api/projects/save_blueprint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, }); set({ currentBlueprintPath: path, lastViewport: payload.viewport }); - await get().refreshProjectTree(); + try { + await get().refreshProjectTree(); + } catch (e) { + console.warn('[saveBlueprintFile] refreshProjectTree failed (file was saved successfully):', e); + } }, readBlueprintFile: async (path: string): Promise<BlueprintDocument> => { @@ -1582,8 +1706,8 @@ const useFlowStore = create<FlowState>((set, get) => { try { await get().saveBlueprintFile(targetPath, viewport); set({ saveStatus: 'saved', currentBlueprintPath: targetPath }); - } catch (e) { - console.error(e); + } catch (e: any) { + console.error('[saveCurrentBlueprint] FAILED:', e?.message || e, '\npath:', targetPath, '\nstack:', e?.stack); set({ saveStatus: 'error' }); throw e; } @@ -1991,6 +2115,94 @@ const useFlowStore = create<FlowState>((set, get) => { }, 50); }, + unfoldMergedTrace: (nodeId: string, mergedTraceId: string) => { + const { nodes, edges, updateNodeData } = get(); + const node = nodes.find(n => n.id === nodeId); + if (!node) return; + + const merged = (node.data.mergedTraces || []).find((m: MergedTrace) => m.id === mergedTraceId); + if (!merged) return; + + // Extract ordered unique node IDs from merged messages + // Message IDs follow the pattern "{nodeId}-user" / "{nodeId}-assistant" + const seenNodes = new Set<string>(); + const orderedNodeIds: string[] = []; + for (const msg of merged.messages) { + if (!msg.id) continue; + const srcNodeId = msg.id.replace(/-user$/, '').replace(/-assistant$/, ''); + if (srcNodeId === nodeId) continue; // skip current node (it's the merge target) + if (!seenNodes.has(srcNodeId)) { + seenNodes.add(srcNodeId); + orderedNodeIds.push(srcNodeId); + } + } + + if (orderedNodeIds.length === 0) return; + + // The trace originates at the first node in the chain + const traceId = `trace-${orderedNodeIds[0]}`; + const traceColor = getStableColor(orderedNodeIds[0]); + + // Build the full chain: node0 → node1 → ... → nodeN → currentNode + const chain = [...orderedNodeIds, nodeId]; + const newEdges = [...edges]; + + // Helper: find next free input-N handle on a target node + const getNextInputHandle = (targetId: string) => { + const usedInputs = new Set<number>(); + newEdges.forEach(e => { + if (e.target === targetId && e.targetHandle?.startsWith('input-')) { + usedInputs.add(parseInt(e.targetHandle.replace('input-', ''))); + } + }); + let idx = 0; + while (usedInputs.has(idx)) idx++; + return `input-${idx}`; + }; + + for (let i = 0; i < chain.length - 1; i++) { + const src = chain[i]; + const tgt = chain[i + 1]; + + // Skip if an edge already carries this trace between these nodes + const alreadyExists = newEdges.some(e => + e.source === src && e.target === tgt && e.sourceHandle === `trace-${traceId}` + ); + if (alreadyExists) continue; + + newEdges.push({ + id: `unfold-${src}-${tgt}-${Date.now()}-${i}`, + source: src, + target: tgt, + sourceHandle: `trace-${traceId}`, + targetHandle: getNextInputHandle(tgt), + style: { stroke: traceColor, strokeWidth: 2 }, + }); + } + + // Rewire any downstream edges from the old merged trace to the new chain trace + for (let i = 0; i < newEdges.length; i++) { + const e = newEdges[i]; + if (e.source === nodeId && e.sourceHandle === `trace-${mergedTraceId}`) { + newEdges[i] = { + ...e, + sourceHandle: `trace-${traceId}`, + type: undefined, + style: { ...e.style, stroke: traceColor, strokeWidth: 2 }, + data: { ...e.data, isMerged: false, gradient: undefined, colors: undefined }, + }; + } + } + + // Remove the merged trace + const filteredMerged = (node.data.mergedTraces || []).filter((m: MergedTrace) => m.id !== mergedTraceId); + + set({ edges: newEdges }); + updateNodeData(nodeId, { mergedTraces: filteredMerged }); + + setTimeout(() => get().propagateTraces(), 50); + }, + propagateTraces: () => { const { nodes, edges } = get(); @@ -2141,8 +2353,8 @@ const useFlowStore = create<FlowState>((set, get) => { const newHandleId = `trace-${matchedTrace.id}`; // Check if this is a merged trace (need gradient) - // Use the new properties on Trace object - const isMergedTrace = matchedTrace.isMerged || matchedTrace.id.startsWith('merged-'); + // Use the explicit isMerged flag (not ID prefix — unfolded traces keep their merged- ID) + const isMergedTrace = !!matchedTrace.isMerged; const mergedColors = matchedTrace.mergedColors || []; // If colors not on trace, try to find in parent node's mergedTraces (for originator) @@ -2306,27 +2518,27 @@ const useFlowStore = create<FlowState>((set, get) => { node.data.mergedTraces.forEach((merged: MergedTrace) => { // Check if all source traces are still connected - const allSourcesConnected = merged.sourceTraceIds.every(id => + const allSourcesConnected = merged.sourceTraceIds.every(id => uniqueIncoming.some(t => t.id === id) ); - + if (!allSourcesConnected) { // Mark this merged trace for deletion mergedTracesToDelete.push(merged.id); return; // Don't add to outgoing traces } - + // Recompute messages based on the current incoming traces (pass uniqueIncoming for latest data) const updatedMessages = computeMergedMessages(node.id, merged.sourceTraceIds, merged.strategy, uniqueIncoming); - + // Filter out current node's messages from updatedMessages to avoid duplication // (since myResponseMsg will be appended at the end) const nodePrefix = `${node.id}-`; const filteredMessages = updatedMessages.filter(m => !m.id?.startsWith(nodePrefix)); - + // Get prepend messages for this merged trace const mergedPrepend = prependMessages.get(merged.id) || []; - + // Update colors from current traces (preserve multi-colors) const updatedColors = merged.sourceTraceIds.flatMap(id => { const t = uniqueIncoming.find(trace => trace.id === id); @@ -2334,13 +2546,13 @@ const useFlowStore = create<FlowState>((set, get) => { if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; return t.color ? [t.color] : []; }); - + // Combine all messages for this merged trace const mergedMessages = [...mergedPrepend, ...filteredMessages, ...myResponseMsg]; - + // Store updated data for bulk update later updatedMergedMap.set(merged.id, { messages: mergedMessages, colors: updatedColors }); - + // Create a trace-like object for merged output const mergedOutgoing: Trace = { id: merged.id, @@ -2351,7 +2563,7 @@ const useFlowStore = create<FlowState>((set, get) => { mergedColors: updatedColors, sourceTraceIds: merged.sourceTraceIds }; - + myOutgoingTraces.push(mergedOutgoing); }); } |
