summaryrefslogtreecommitdiff
path: root/frontend/src/store/flowStore.ts
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/store/flowStore.ts')
-rw-r--r--frontend/src/store/flowStore.ts510
1 files changed, 361 insertions, 149 deletions
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);
});
}