summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorblackhao <13851610112@163.com>2025-12-09 15:05:04 -0600
committerblackhao <13851610112@163.com>2025-12-09 15:05:04 -0600
commitc3673766aecdb988bb4e811376d4f1f1e18f1e0f (patch)
treeaeff0bdb718e2ad472c02a29327f5c5c01c41e18
parenta14a53edbacb24051a31e73e2d111307c2f0354e (diff)
some fix on trace merging
-rw-r--r--frontend/src/components/Sidebar.tsx240
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx74
-rw-r--r--frontend/src/store/flowStore.ts298
3 files changed, 432 insertions, 180 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 5516629..06c8704 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -544,56 +544,118 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
return true;
};
- // Helper: Check if specific trace path upstream has complete nodes
- const checkTracePathComplete = (nodeId: string, traceId: string, visited: Set<string> = new Set()): boolean => {
- if (visited.has(nodeId)) return true;
- visited.add(nodeId);
-
- // Find the incoming edge that carries this trace
- const incomingEdge = edges.find(e =>
- e.target === nodeId &&
- e.sourceHandle === `trace-${traceId}`
- );
-
- if (!incomingEdge) return true; // Reached head of trace segment
-
+ // Helper: find incoming edge for a given trace ID (with fallbacks)
+ const findIncomingEdgeForTrace = (nodeId: string, traceId: string): Edge | null => {
+ // 1) exact match by sourceHandle
+ let edge = edges.find(e => e.target === nodeId && e.sourceHandle === `trace-${traceId}`);
+ if (edge) return edge;
+ // 2) fallback: any incoming edge whose source has this trace in outgoingTraces
+ edge = edges.find(e => {
+ if (e.target !== nodeId) return false;
+ const src = nodes.find(n => n.id === e.source);
+ return src?.data.outgoingTraces?.some((t: Trace) => t.id === traceId);
+ });
+ return edge || null;
+ };
+
+ // Helper: get source trace IDs for a merged trace on a given node (supports propagated merged traces)
+ const getMergedSourceIds = (nodeId: string, traceId: string): string[] => {
+ const node = nodes.find(n => n.id === nodeId);
+ if (!node) return [];
+ const mergedLocal = node.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
+ if (mergedLocal) return mergedLocal.sourceTraceIds || [];
+ const incomingMatch = node.data.traces?.find((t: Trace) => t.id === traceId);
+ if (incomingMatch?.isMerged && incomingMatch.sourceTraceIds) return incomingMatch.sourceTraceIds;
+ const outgoingMatch = node.data.outgoingTraces?.find((t: Trace) => t.id === traceId);
+ if (outgoingMatch?.isMerged && outgoingMatch.sourceTraceIds) return outgoingMatch.sourceTraceIds;
+ return [];
+ };
+
+ // Recursive: Check if specific trace path upstream has complete nodes (supports multi-level merged)
+ const checkTracePathComplete = (
+ nodeId: string,
+ traceId: string,
+ visited: Set<string> = new Set()
+ ): boolean => {
+ const visitKey = `${nodeId}-${traceId}`;
+ if (visited.has(visitKey)) return true;
+ visited.add(visitKey);
+
+ // Determine if this node is the merge owner or just receiving a propagated merged trace
+ const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId);
+ const localParents = getMergedSourceIds(nodeId, traceId);
+
+ const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId);
+ if (!incomingEdge) {
+ // If no incoming edge and this node owns the merge, check parents from here
+ if (localMerge && localParents.length > 0) {
+ for (const pid of localParents) {
+ if (!checkTracePathComplete(nodeId, pid, visited)) return false;
+ }
+ return true;
+ }
+ return true; // head
+ }
+
const sourceNode = nodes.find(n => n.id === incomingEdge.source);
if (!sourceNode || sourceNode.data.disabled) return true;
-
- // Check if source node is complete
- if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
- return false; // Found incomplete node
+
+ // If merged at sourceNode (or propagated merged), recurse into each parent from the merge owner
+ const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId);
+ if (parentIds.length > 0) {
+ const mergeOwnerId = localMerge ? nodeId : sourceNode.id;
+ for (const pid of parentIds) {
+ if (!checkTracePathComplete(mergeOwnerId, pid, visited)) return false;
+ }
+ return true;
}
-
- // Continue upstream
+
+ // Regular trace: check node content then continue upstream
+ if (!sourceNode.data.userPrompt || !sourceNode.data.response) return false;
return checkTracePathComplete(sourceNode.id, traceId, visited);
};
- // Helper: Find the first empty node on a specific trace path
- const findEmptyNodeOnTrace = (nodeId: string, traceId: string, visited: Set<string> = new Set()): string | null => {
- if (visited.has(nodeId)) return null;
- visited.add(nodeId);
-
- const incomingEdge = edges.find(e =>
- e.target === nodeId &&
- e.sourceHandle === `trace-${traceId}`
- );
-
- if (!incomingEdge) return null;
-
+ // Recursive: Find the first empty node on a specific trace path (supports multi-level merged)
+ const findEmptyNodeOnTrace = (
+ nodeId: string,
+ traceId: string,
+ visited: Set<string> = new Set()
+ ): string | null => {
+ const visitKey = `${nodeId}-${traceId}`;
+ if (visited.has(visitKey)) return null;
+ visited.add(visitKey);
+
+ // Determine if this node owns the merge or just receives propagated merged trace
+ const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId);
+ const localParents = getMergedSourceIds(nodeId, traceId);
+
+ const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId);
+ if (!incomingEdge) {
+ if (localMerge && localParents.length > 0) {
+ for (const pid of localParents) {
+ const upstreamEmpty = findEmptyNodeOnTrace(nodeId, pid, visited);
+ if (upstreamEmpty) return upstreamEmpty;
+ }
+ }
+ return null;
+ }
+
const sourceNode = nodes.find(n => n.id === incomingEdge.source);
if (!sourceNode || sourceNode.data.disabled) return null;
-
- // Recursively check upstream first (find the furthest empty node)
- const upstreamEmpty = findEmptyNodeOnTrace(sourceNode.id, traceId, visited);
- if (upstreamEmpty) return upstreamEmpty;
-
- // If no further upstream empty, check this node
+
+ const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId);
+ if (parentIds.length > 0) {
+ const mergeOwnerId = localMerge ? nodeId : sourceNode.id;
+ for (const pid of parentIds) {
+ const upstreamEmpty = findEmptyNodeOnTrace(mergeOwnerId, pid, visited);
+ if (upstreamEmpty) return upstreamEmpty;
+ }
+ }
+
if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
return sourceNode.id;
}
-
- return null;
+ return findEmptyNodeOnTrace(sourceNode.id, traceId, visited);
};
// Check if all active traces are complete (for main Run Node button)
@@ -603,23 +665,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const activeTraceIds = selectedNode.data.activeTraceIds || [];
if (activeTraceIds.length === 0) return { complete: true };
- // Check upstream nodes ONLY for active traces
+ // Check upstream nodes ONLY for active traces (supports merged trace recursion)
for (const traceId of activeTraceIds) {
- // Check if it's a merged trace
- const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
-
- if (merged) {
- // For merged trace, check all source traces
- for (const sourceId of merged.sourceTraceIds) {
- if (!checkTracePathComplete(selectedNode.id, sourceId)) {
- return { complete: false, incompleteTraceId: 'upstream' };
- }
- }
- } else {
- // For regular trace
- if (!checkTracePathComplete(selectedNode.id, traceId)) {
- return { complete: false, incompleteTraceId: 'upstream' };
- }
+ if (!checkTracePathComplete(selectedNode.id, traceId)) {
+ return { complete: false, incompleteTraceId: 'upstream' };
}
}
@@ -632,12 +681,11 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
}
}
- // Check merged traces content
- const mergedTraces = selectedNode.data.mergedTraces || [];
+ // Check merged traces content (including propagated merged traces)
for (const traceId of activeTraceIds) {
- const merged = mergedTraces.find((m: MergedTrace) => m.id === traceId);
- if (merged) {
- for (const sourceId of merged.sourceTraceIds) {
+ const sourceIds = getMergedSourceIds(selectedNode.id, traceId);
+ if (sourceIds.length > 0) {
+ for (const sourceId of sourceIds) {
const sourceTrace = incomingTraces.find((t: Trace) => t.id === sourceId);
if (sourceTrace && !isTraceComplete(sourceTrace)) {
return { complete: false, incompleteTraceId: sourceId };
@@ -655,18 +703,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
const activeTraceIds = selectedNode.data.activeTraceIds || [];
for (const traceId of activeTraceIds) {
- let emptyNodeId: string | null = null;
-
- const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
- if (merged) {
- for (const sourceId of merged.sourceTraceIds) {
- emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, sourceId);
- if (emptyNodeId) break;
- }
- } else {
- emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, traceId);
- }
-
+ const emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, traceId);
if (emptyNodeId) {
const emptyNode = nodes.find(n => n.id === emptyNodeId);
if (emptyNode) {
@@ -1237,8 +1274,32 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<div className="flex-1">
<div className="flex items-center gap-2">
- <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div>
- <span className={`font-mono text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>#{trace.id.slice(-4)}</span>
+ {trace.isMerged ? (
+ // Merged Trace Rendering (for propagated merged traces)
+ <div className="flex -space-x-1 shrink-0">
+ {(trace.mergedColors || [trace.color]).slice(0, 3).map((color, idx) => (
+ <div
+ key={idx}
+ className="w-2 h-2 rounded-full border-2"
+ style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }}
+ />
+ ))}
+ {(trace.mergedColors?.length || 0) > 3 && (
+ <div className={`w-2 h-2 rounded-full flex items-center justify-center text-[6px] ${
+ isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500'
+ }`}>
+ +
+ </div>
+ )}
+ </div>
+ ) : (
+ // Regular Trace Rendering
+ <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div>
+ )}
+
+ <span className={`font-mono text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ {trace.isMerged ? 'Merged ' : ''}#{trace.id.slice(-4)}
+ </span>
{!isComplete && (
<span className="text-[9px] text-orange-500">(incomplete)</span>
)}
@@ -1359,10 +1420,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
e.stopPropagation();
openMergedQuickChat(merged);
}}
+ disabled={!isComplete}
className={`p-1 rounded shrink-0 ${
- isDark ? 'hover:bg-purple-900 text-gray-500 hover:text-purple-400' : 'hover:bg-purple-100 text-gray-400 hover:text-purple-600'
+ isComplete
+ ? isDark ? 'hover:bg-purple-900 text-gray-500 hover:text-purple-400' : 'hover:bg-purple-100 text-gray-400 hover:text-purple-600'
+ : 'text-gray-500 cursor-not-allowed opacity-50'
}`}
- title="Quick Chat with merged context"
+ title={isComplete ? "Quick Chat with merged context" : "Trace incomplete"}
>
<MessageCircle size={12} />
</button>
@@ -1473,12 +1537,18 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
<textarea
value={editedResponse}
onChange={(e) => setEditedResponse(e.target.value)}
- className="w-full border border-blue-300 rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500"
+ className={`w-full border rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500 ${
+ isDark
+ ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500'
+ : 'bg-white border-blue-300 text-gray-900'
+ }`}
/>
<div className="flex gap-2 justify-end">
<button
onClick={handleCancelEdit}
- className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded flex items-center gap-1"
+ className={`px-3 py-1 text-sm rounded flex items-center gap-1 ${
+ isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100'
+ }`}
>
<X size={14} /> Cancel
</button>
@@ -1493,8 +1563,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
) : (
<div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${
isDark
- ? 'bg-gray-900 border-gray-700 prose-invert'
- : 'bg-gray-50 border-gray-200'
+ ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200'
+ : 'bg-gray-50 border-gray-200 text-gray-900'
}`}>
<ReactMarkdown>{selectedNode.data.response || (streamingNodeId === selectedNode.id ? streamBuffer : '')}</ReactMarkdown>
</div>
@@ -1506,22 +1576,24 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
{activeTab === 'settings' && (
<div className="space-y-4">
<div>
- <label className="block text-sm font-medium text-gray-700 mb-1">Merge Strategy</label>
+ <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label>
<select
value={selectedNode.data.mergeStrategy || 'smart'}
onChange={(e) => handleChange('mergeStrategy', e.target.value)}
- className="w-full border border-gray-300 rounded-md p-2 text-sm"
+ className={`w-full border rounded-md p-2 text-sm ${
+ isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 bg-white text-gray-900'
+ }`}
>
<option value="smart">Smart (Auto-merge roles)</option>
<option value="raw">Raw (Concatenate)</option>
</select>
- <p className="text-xs text-gray-500 mt-1">
+ <p className={`text-xs mt-1 ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
Smart merge combines consecutive messages from the same role to avoid API errors.
</p>
</div>
<div>
- <label className="block text-sm font-medium text-gray-700 mb-1">
+ <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Temperature ({selectedNode.data.temperature})
{[
'gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano',
diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx
index 8105070..d2e1293 100644
--- a/frontend/src/components/nodes/LLMNode.tsx
+++ b/frontend/src/components/nodes/LLMNode.tsx
@@ -217,43 +217,8 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
})
}
- {/* Prepend input handles for merged traces - only show if merged trace has downstream */}
- {data.mergedTraces && data.mergedTraces
- .filter((merged: MergedTrace) => {
- // Check if this merged trace has any outgoing edges
- return edges.some(e =>
- e.source === id && e.sourceHandle === `trace-${merged.id}`
- );
- })
- .map((merged: MergedTrace) => {
- const connectedEdge = edges.find(e => e.target === id && e.targetHandle === `prepend-${merged.id}`);
- const edgeColor = connectedEdge?.style?.stroke as string;
-
- // Create gradient for merged trace handle
- const colors = merged.colors.length > 0 ? merged.colors : ['#888'];
- const gradientStops = colors.map((color, idx) =>
- `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
- ).join(', ');
- const stripeGradient = `linear-gradient(45deg, ${gradientStops})`;
-
- return (
- <div key={`prepend-${merged.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to merged: ${merged.id}`}>
- <Handle
- type="target"
- position={Position.Left}
- id={`prepend-${merged.id}`}
- className="!w-3 !h-3 !left-[-6px] !border-2"
- style={{
- top: '50%',
- transform: 'translateY(-50%)',
- background: edgeColor || stripeGradient,
- borderColor: isDark ? '#374151' : '#fff',
- borderStyle: 'dashed'
- }}
- />
- </div>
- );
- })}
+ {/* Prepend input handles for merged traces - REMOVED per user request */}
+ {/* Users should prepend to parent traces instead */}
</div>
{/* Dynamic Outputs (Traces) */}
@@ -272,6 +237,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
return !hasOutgoingEdge;
})
.map((trace: Trace) => {
+ // Handle merged trace visualization
+ let backgroundStyle = trace.color;
+ if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) {
+ const colors = trace.mergedColors;
+ const gradientStops = colors.map((color, idx) =>
+ `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
+ ).join(', ');
+ backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+ }
+
return (
<div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}>
<Handle
@@ -280,7 +255,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
id={`trace-${trace.id}`}
className="!w-3 !h-3 !right-[-6px]"
style={{
- backgroundColor: trace.color,
+ background: backgroundStyle,
top: '50%',
transform: 'translateY(-50%)',
border: `2px dashed ${isDark ? '#374151' : '#fff'}`
@@ -294,8 +269,9 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
{/* Only show traces that have actual downstream edges */}
{data.outgoingTraces && data.outgoingTraces
.filter(trace => {
- // Exclude merged traces - they have their own section
- if (trace.id.startsWith('merged-')) return false;
+ // Check if this is a locally created merged trace (should be shown in Part 2)
+ const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id);
+ if (isLocallyMerged) return false;
// Only show if there's an actual downstream edge using this trace
const hasDownstream = edges.some(e =>
@@ -303,7 +279,18 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
);
return hasDownstream;
})
- .map((trace) => (
+ .map((trace) => {
+ // Handle merged trace visualization
+ let backgroundStyle = trace.color;
+ if (trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0) {
+ const colors = trace.mergedColors;
+ const gradientStops = colors.map((color, idx) =>
+ `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
+ ).join(', ');
+ backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+ }
+
+ return (
<div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}>
<Handle
type="source"
@@ -311,13 +298,14 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
id={`trace-${trace.id}`}
className="!w-3 !h-3 !right-[-6px]"
style={{
- backgroundColor: trace.color,
+ background: backgroundStyle,
top: '50%',
transform: 'translateY(-50%)'
}}
/>
</div>
- ))}
+ );
+ })}
{/* 2. Merged Trace Handles (with alternating color stripes) */}
{data.mergedTraces && data.mergedTraces.map((merged: MergedTrace) => {
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
index 49a8ece..5ed66e6 100644
--- a/frontend/src/store/flowStore.ts
+++ b/frontend/src/store/flowStore.ts
@@ -30,6 +30,10 @@ export interface Trace {
sourceNodeId: string;
color: string;
messages: Message[];
+ // Optional merged trace info for visual propagation
+ isMerged?: boolean;
+ mergedColors?: string[];
+ sourceTraceIds?: string[];
}
// Merge strategy types
@@ -200,12 +204,8 @@ const useFlowStore = create<FlowState>((set, get) => ({
const isOverlapping = (testX: number, testY: number) => {
return nodes.some(node => {
- // Use the same estimated dimensions for existing nodes too
- // Ideally we would know their actual dimensions, but this is a safe approximation
const nodeX = node.position.x;
const nodeY = node.position.y;
-
- // Check for overlap
return !(testX + nodeWidth + padding < nodeX ||
testX > nodeX + nodeWidth + padding ||
testY + nodeHeight + padding < nodeY ||
@@ -383,7 +383,12 @@ const useFlowStore = create<FlowState>((set, get) => ({
}
// Helper to trace back the path of a trace by following edges upstream
- const duplicateTracePath = (traceId: string, forkAtNodeId: string): { newTraceId: string, newEdges: Edge[], firstNodeId: string } | null => {
+ const duplicateTracePath = (
+ 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
@@ -393,10 +398,10 @@ const useFlowStore = create<FlowState>((set, get) => ({
// Trace backwards through incoming edges
while (true) {
- // Find incoming edge to current node that's part of this trace
+ // Find incoming edge to current node that carries THIS trace ID
const incomingEdge = edges.find(e =>
e.target === currentNodeId &&
- e.sourceHandle?.startsWith('trace-')
+ e.sourceHandle === `trace-${traceId}`
);
if (!incomingEdge) break; // Reached the start of the trace
@@ -413,14 +418,13 @@ const useFlowStore = create<FlowState>((set, get) => ({
const firstNode = nodes.find(n => n.id === firstNodeId);
if (!firstNode) return null;
- // Create a new trace ID for the duplicated path
- const timestamp = Date.now();
- const newTraceId = `fork-${firstNodeId}-${timestamp}`;
+ // 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
const newEdges: Edge[] = [];
- let evolvedTraceId = newTraceId;
// Track which input handles we're creating for new edges
const newInputHandles: Map<string, number> = new Map();
@@ -432,26 +436,30 @@ const useFlowStore = create<FlowState>((set, get) => ({
// Find the next available input handle for the target node
// Count existing edges to this node + any new edges we're creating
- const existingEdgesToTarget = edges.filter(e => e.target === toNodeId).length;
+ 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-${timestamp}-${i}`,
+ id: `edge-fork-${uniq}-${i}`,
source: fromNodeId,
target: toNodeId,
- sourceHandle: `trace-${evolvedTraceId}`,
+ sourceHandle: `trace-${newTraceId}`,
targetHandle: `input-${nextInputIndex}`,
style: { stroke: newTraceColor, strokeWidth: 2 }
});
-
- // Evolve trace ID for next edge
- evolvedTraceId = `${evolvedTraceId}_${toNodeId}`;
}
// Find the messages up to the fork point
- const originalTrace = sourceNode.data.outgoingTraces?.find(t => t.id === traceId);
+ const traceOwnerNode = traceOwnerNodeId
+ ? nodes.find(n => n.id === traceOwnerNodeId)
+ : sourceNode;
+ if (!traceOwnerNode) return null;
+
+ const originalTrace = traceOwnerNode.data.outgoingTraces?.find(t => t.id === traceId);
const messagesUpToFork = originalTrace?.messages || [];
// Add the new trace as a forked trace on the first node
@@ -468,6 +476,161 @@ const useFlowStore = create<FlowState>((set, get) => ({
return { newTraceId, newEdges, firstNodeId };
};
+
+ // Helper to duplicate the downstream segment of a trace from a start node to an end node
+ const duplicateDownstreamSegment = (
+ originalTraceId: string,
+ startNodeId: string,
+ endNodeId: string,
+ newTraceId: string,
+ newTraceColor: string,
+ newTraceColors: string[]
+ ): Edge[] | null => {
+ const segmentEdges: Edge[] = [];
+ let currentNodeId = startNodeId;
+ const visitedEdgeIds = new Set<string>();
+
+ while (currentNodeId !== endNodeId) {
+ const nextEdge = edges.find(
+ (e) => e.source === currentNodeId && e.sourceHandle === `trace-${originalTraceId}`
+ );
+
+ if (!nextEdge || visitedEdgeIds.has(nextEdge.id)) {
+ return null;
+ }
+
+ segmentEdges.push(nextEdge);
+ visitedEdgeIds.add(nextEdge.id);
+ currentNodeId = nextEdge.target;
+ }
+
+ const newEdges: Edge[] = [];
+ const newInputCounts: Map<string, number> = new Map();
+ const segmentTimestamp = Date.now();
+
+ segmentEdges.forEach((edge, index) => {
+ const targetNodeId = edge.target;
+ const existingEdgesToTarget = edges.filter((e) => e.target === targetNodeId).length;
+ const additionalEdges = newInputCounts.get(targetNodeId) || 0;
+ const nextInputIndex = existingEdgesToTarget + additionalEdges;
+ newInputCounts.set(targetNodeId, additionalEdges + 1);
+
+ newEdges.push({
+ id: `edge-merged-seg-${segmentTimestamp}-${index}`,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: `trace-${newTraceId}`,
+ targetHandle: `input-${nextInputIndex}`,
+ type: 'merged',
+ style: { stroke: newTraceColor, strokeWidth: 2 },
+ data: { isMerged: true, colors: newTraceColors }
+ });
+ });
+
+ return newEdges;
+ };
+
+ // Helper to duplicate a merged trace by cloning its parent traces and creating a new merged branch
+ const duplicateMergedTraceBranch = (
+ mergedTrace: Trace,
+ forkAtNodeId: string
+ ): { newTraceId: string; newEdges: Edge[]; color: string } | null => {
+ const mergeNodeId = mergedTrace.sourceNodeId;
+ const mergeNode = nodes.find((n) => n.id === mergeNodeId);
+ if (!mergeNode) return null;
+
+ const mergedDef =
+ mergeNode.data.mergedTraces?.find((m: MergedTrace) => m.id === mergedTrace.id) || null;
+ const parentTraceIds = mergedTrace.sourceTraceIds || mergedDef?.sourceTraceIds || [];
+ if (parentTraceIds.length === 0) return null;
+
+ let accumulatedEdges: Edge[] = [];
+ const newParentTraceIds: string[] = [];
+ const parentOverrides: Trace[] = [];
+
+ for (const parentId of parentTraceIds) {
+ const originalParentTrace = mergeNode.data.traces?.find((t: Trace) => t.id === parentId);
+
+ if (originalParentTrace?.isMerged && originalParentTrace.sourceTraceIds?.length) {
+ const nestedDuplicate = duplicateMergedTraceBranch(originalParentTrace, mergeNodeId);
+ if (!nestedDuplicate) {
+ return null;
+ }
+ accumulatedEdges = accumulatedEdges.concat(nestedDuplicate.newEdges);
+ newParentTraceIds.push(nestedDuplicate.newTraceId);
+
+ parentOverrides.push({
+ ...originalParentTrace,
+ id: nestedDuplicate.newTraceId,
+ });
+
+ continue;
+ }
+
+ const duplicateResult = duplicateTracePath(parentId, mergeNodeId, mergeNodeId, accumulatedEdges);
+ if (!duplicateResult) {
+ return null;
+ }
+ accumulatedEdges = accumulatedEdges.concat(duplicateResult.newEdges);
+ newParentTraceIds.push(duplicateResult.newTraceId);
+
+ if (originalParentTrace) {
+ parentOverrides.push({
+ ...originalParentTrace,
+ id: duplicateResult.newTraceId,
+ });
+ }
+ }
+
+ const strategy = mergedDef?.strategy || 'trace_order';
+ const uniqMerged = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const newMergedId = `merged-${mergeNodeId}-${uniqMerged}`;
+ const newColors =
+ parentOverrides.length > 0
+ ? parentOverrides.map((t) => t.color).filter((c): c is string => Boolean(c))
+ : mergedTrace.mergedColors ?? [];
+
+ const overrideTraces = parentOverrides.length > 0 ? parentOverrides : undefined;
+ const mergedMessages = get().computeMergedMessages(
+ mergeNodeId,
+ newParentTraceIds,
+ strategy,
+ overrideTraces
+ );
+
+ const newMergedDefinition: MergedTrace = {
+ id: newMergedId,
+ sourceNodeId: mergeNodeId,
+ sourceTraceIds: newParentTraceIds,
+ strategy,
+ colors: newColors.length ? newColors : [mergedTrace.color],
+ messages: mergedMessages,
+ };
+
+ const existingMerged = mergeNode.data.mergedTraces || [];
+ get().updateNodeData(mergeNodeId, {
+ mergedTraces: [...existingMerged, newMergedDefinition],
+ });
+
+ const newMergedColor = newColors[0] || mergedTrace.color || getStableColor(newMergedId);
+ const downstreamEdges = duplicateDownstreamSegment(
+ mergedTrace.id,
+ mergeNodeId,
+ forkAtNodeId,
+ newMergedId,
+ newMergedColor,
+ newColors.length ? newColors : [mergedTrace.color]
+ );
+ if (!downstreamEdges) return null;
+
+ accumulatedEdges = accumulatedEdges.concat(downstreamEdges);
+
+ return {
+ newTraceId: newMergedId,
+ newEdges: accumulatedEdges,
+ color: newMergedColor,
+ };
+ };
// Helper to create a simple forked trace (for new-trace handle or first connection)
const createSimpleForkTrace = () => {
@@ -527,30 +690,40 @@ const useFlowStore = create<FlowState>((set, get) => ({
);
if (existingEdgeFromHandle && connection.sourceHandle?.startsWith('trace-')) {
- // This handle already has a connection - need to duplicate the entire upstream trace path
const originalTraceId = connection.sourceHandle.replace('trace-', '');
+ const traceMeta = sourceNode.data.outgoingTraces?.find((t: Trace) => t.id === originalTraceId);
+
+ if (traceMeta?.isMerged && traceMeta.sourceTraceIds && traceMeta.sourceTraceIds.length > 0) {
+ const mergedDuplicate = duplicateMergedTraceBranch(traceMeta, connection.source!);
+ if (mergedDuplicate) {
+ set({
+ edges: [
+ ...get().edges,
+ ...mergedDuplicate.newEdges,
+ {
+ id: `edge-${connection.source}-${connection.target}-${Date.now()}`,
+ source: connection.source!,
+ target: connection.target!,
+ sourceHandle: `trace-${mergedDuplicate.newTraceId}`,
+ targetHandle: connection.targetHandle,
+ type: 'merged',
+ style: { stroke: mergedDuplicate.color, strokeWidth: 2 },
+ data: { isMerged: true, colors: traceMeta.mergedColors || [] }
+ } as Edge
+ ],
+ });
+
+ setTimeout(() => get().propagateTraces(), 0);
+ return;
+ }
+ }
+
const duplicateResult = duplicateTracePath(originalTraceId, connection.source!);
if (duplicateResult) {
- // Add all the duplicated edges plus the new connection
- const { newTraceId, newEdges, firstNodeId } = duplicateResult;
+ const { newTraceId, newEdges } = duplicateResult;
const newTraceColor = getStableColor(newTraceId);
- // Calculate the evolved trace ID at the fork node
- // The trace evolves through each node: trace-A -> trace-A_B -> trace-A_B_C
- // We need to build the evolved ID based on the path
- let evolvedTraceId = newTraceId;
-
- // Find the path from first node to fork node by looking at the new edges
- for (const edge of newEdges) {
- if (edge.target === connection.source) {
- // This edge ends at our fork node, so the evolved trace ID is after this edge
- evolvedTraceId = `${evolvedTraceId}_${edge.target}`;
- break;
- }
- evolvedTraceId = `${evolvedTraceId}_${edge.target}`;
- }
-
set({
edges: [
...get().edges,
@@ -559,7 +732,7 @@ const useFlowStore = create<FlowState>((set, get) => ({
id: `edge-${connection.source}-${connection.target}-${Date.now()}`,
source: connection.source!,
target: connection.target!,
- sourceHandle: `trace-${evolvedTraceId}`,
+ sourceHandle: `trace-${newTraceId}`,
targetHandle: connection.targetHandle,
style: { stroke: newTraceColor, strokeWidth: 2 }
} as Edge
@@ -569,7 +742,6 @@ const useFlowStore = create<FlowState>((set, get) => ({
setTimeout(() => get().propagateTraces(), 0);
return;
} else {
- // Fallback to simple fork if path duplication fails
const newForkTrace = createSimpleForkTrace();
set({
@@ -1561,24 +1733,38 @@ const useFlowStore = create<FlowState>((set, get) => ({
const newHandleId = `trace-${matchedTrace.id}`;
// Check if this is a merged trace (need gradient)
- const isMergedTrace = matchedTrace.id.startsWith('merged-');
+ // Use the new properties on Trace object
+ const isMergedTrace = matchedTrace.isMerged || matchedTrace.id.startsWith('merged-');
+ const mergedColors = matchedTrace.mergedColors || [];
+
+ // If colors not on trace, try to find in parent node's mergedTraces (for originator)
+ let finalColors = mergedColors;
+ if (isMergedTrace && finalColors.length === 0) {
const parentNode = nodes.find(n => n.id === edge.source);
- const mergedTraceData = isMergedTrace
- ? parentNode?.data.mergedTraces?.find((m: MergedTrace) => m.id === matchedTrace.id)
- : null;
+ const mergedData = parentNode?.data.mergedTraces?.find((m: MergedTrace) => m.id === matchedTrace.id);
+ if (mergedData) finalColors = mergedData.colors;
+ }
// Create gradient for merged traces
let gradient: string | undefined;
- if (mergedTraceData && mergedTraceData.colors.length > 0) {
- const colors = mergedTraceData.colors;
- const gradientStops = colors.map((color: string, idx: number) =>
- `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
+ if (finalColors.length > 0) {
+ const gradientStops = finalColors.map((color: string, idx: number) =>
+ `${color} ${(idx / finalColors.length) * 100}%, ${color} ${((idx + 1) / finalColors.length) * 100}%`
).join(', ');
gradient = `linear-gradient(90deg, ${gradientStops})`;
}
// Check if we need to update
- if (currentEdge.sourceHandle !== newHandleId || currentEdge.style?.stroke !== matchedTrace.color) {
+ // Update if handle changed OR color changed OR merged status/colors changed
+ const currentIsMerged = currentEdge.data?.isMerged;
+ const currentColors = currentEdge.data?.colors;
+ const colorsChanged = JSON.stringify(currentColors) !== JSON.stringify(finalColors);
+
+ if (currentEdge.sourceHandle !== newHandleId ||
+ currentEdge.style?.stroke !== matchedTrace.color ||
+ currentIsMerged !== isMergedTrace ||
+ colorsChanged) {
+
updatedEdges[edgeIndex] = {
...currentEdge,
sourceHandle: newHandleId,
@@ -1588,7 +1774,7 @@ const useFlowStore = create<FlowState>((set, get) => ({
...currentEdge.data,
gradient,
isMerged: isMergedTrace,
- colors: mergedTraceData?.colors || []
+ colors: finalColors
}
};
edgesChanged = true;
@@ -1749,7 +1935,10 @@ const useFlowStore = create<FlowState>((set, get) => ({
id: merged.id,
sourceNodeId: node.id,
color: updatedColors[0] || getStableColor(merged.id),
- messages: mergedMessages
+ messages: mergedMessages,
+ isMerged: true,
+ mergedColors: updatedColors,
+ sourceTraceIds: merged.sourceTraceIds
};
myOutgoingTraces.push(mergedOutgoing);
@@ -1775,19 +1964,22 @@ const useFlowStore = create<FlowState>((set, get) => ({
});
// Bulk Update Store
+ const uniqTraces = (list: Trace[]) => Array.from(new Map(list.map(t => [t.id, t])).values());
+ const uniqMerged = (list: MergedTrace[]) => Array.from(new Map(list.map(m => [m.id, m])).values());
+
set(state => ({
edges: updatedEdges,
nodes: state.nodes.map(n => {
- const traces = nodeIncomingTraces.get(n.id) || [];
- const outTraces = nodeOutgoingTraces.get(n.id) || [];
+ const traces = uniqTraces(nodeIncomingTraces.get(n.id) || []);
+ const outTraces = uniqTraces(nodeOutgoingTraces.get(n.id) || []);
const mergedToDelete = nodeMergedTracesToDelete.get(n.id) || [];
const updatedMerged = nodeUpdatedMergedTraces.get(n.id);
const cleanedForks = nodeForkedTracesToClean.get(n.id);
// Filter out disconnected merged traces and update messages for remaining ones
- let filteredMergedTraces = (n.data.mergedTraces || []).filter(
+ let filteredMergedTraces = uniqMerged((n.data.mergedTraces || []).filter(
(m: MergedTrace) => !mergedToDelete.includes(m.id)
- );
+ ));
// Apply updated messages and colors to merged traces
if (updatedMerged && updatedMerged.size > 0) {