summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-02-13 05:07:46 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-02-13 05:07:46 +0000
commitb6f21c210ee804782eba2e7c30c2ccdcbd95bffb (patch)
tree4ff355d72a511063ba366c5052300cf1ca6f60a6
parent7d897ad9bb5ee46839ec91992cbbf4593168f119 (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.tsx98
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx77
-rw-r--r--frontend/src/store/flowStore.ts510
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);
});
}