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 /frontend/src/components | |
| 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>
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 98 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 77 |
2 files changed, 140 insertions, 35 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'}`}> |
