import { create } from 'zustand'; import { addEdge, applyNodeChanges, applyEdgeChanges, type Connection, type Edge, type EdgeChange, type Node, type NodeChange, type OnNodesChange, type OnEdgesChange, type OnConnect, getIncomers, getOutgoers } from 'reactflow'; // --- Project / Blueprint types --- export interface ViewportState { x: number; y: number; zoom: number; } export interface BlueprintDocument { version: number; nodes: Node[]; edges: Edge[]; viewport?: ViewportState; theme?: 'light' | 'dark'; } export interface FSItem { name: string; path: string; // path relative to user root type: 'file' | 'folder'; size?: number | null; mtime?: number | null; children?: FSItem[]; } export type NodeStatus = 'idle' | 'loading' | 'success' | 'error'; export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export interface Message { id?: string; role: 'user' | 'assistant' | 'system'; content: string; sourceTraceId?: string; // For merged traces: which trace this message came from sourceTraceColor?: string; // For merged traces: color of the source trace } export interface Trace { id: string; sourceNodeId: string; color: string; messages: Message[]; // Optional merged trace info for visual propagation isMerged?: boolean; mergedColors?: string[]; sourceTraceIds?: string[]; } // Merge strategy types export type MergeStrategy = 'query_time' | 'response_time' | 'trace_order' | 'grouped' | 'interleaved' | 'summary'; // Merged trace - combines multiple traces with a strategy export interface MergedTrace { id: string; sourceNodeId: string; sourceTraceIds: string[]; // IDs of traces being merged (in order for trace_order) strategy: MergeStrategy; colors: string[]; // Colors from source traces (for alternating display) messages: Message[]; // Computed merged messages summarizedContent?: string; // For summary strategy, stores the LLM-generated summary } export interface NodeData { label: string; model: string; temperature: number; apiKey?: string; systemPrompt: string; userPrompt: string; mergeStrategy: 'raw' | 'smart'; enableGoogleSearch?: boolean; reasoningEffort: 'low' | 'medium' | 'high'; // For OpenAI reasoning models attachedFileIds?: string[]; // IDs of files attached to this node disabled?: boolean; // Greyed out, no interaction // Traces logic traces: Trace[]; // INCOMING Traces outgoingTraces: Trace[]; // ALL Outgoing (inherited + self + forks + merged) forkedTraces: Trace[]; // Manually created forks from "New" handle mergedTraces: MergedTrace[]; // Merged traces from multiple inputs activeTraceIds: string[]; response: string; status: NodeStatus; inputs: number; // Timestamps for merge logic querySentAt?: number; // Unix timestamp when query was sent responseReceivedAt?: number; // Unix timestamp when response was received [key: string]: any; } export type LLMNode = Node; // Archived node template (for reuse) export interface ArchivedNode { id: string; label: string; model: string; systemPrompt: string; temperature: number; reasoningEffort: 'low' | 'medium' | 'high'; userPrompt?: string; response?: string; enableGoogleSearch?: boolean; attachedFileIds?: string[]; mergeStrategy?: 'raw' | 'smart'; } export interface FileMeta { id: string; name: string; size: number; mime: string; created_at: number; provider?: string; provider_file_id?: string; scopes?: string[]; // "project_path/node_id" composite keys } export type UploadFileProvider = 'local' | 'openai' | 'google'; export interface UploadFileOptions { provider?: UploadFileProvider; purpose?: string; } interface FlowState { nodes: LLMNode[]; edges: Edge[]; selectedNodeId: string | null; archivedNodes: ArchivedNode[]; // Stored node templates files: FileMeta[]; uploadingFileIds?: string[]; theme: 'light' | 'dark'; projectTree: FSItem[]; currentBlueprintPath?: string; lastViewport?: ViewportState; saveStatus: SaveStatus; onNodesChange: OnNodesChange; onEdgesChange: OnEdgesChange; onConnect: OnConnect; addNode: (node: LLMNode) => void; toggleTheme: () => void; autoLayout: () => void; findNonOverlappingPosition: (baseX: number, baseY: number) => { x: number; y: number }; updateNodeData: (nodeId: string, data: Partial) => void; setSelectedNode: (nodeId: string | null) => void; getActiveContext: (nodeId: string) => Message[]; // Actions deleteEdge: (edgeId: string) => void; deleteNode: (nodeId: string) => void; deleteBranch: (startNodeId?: string, startEdgeId?: string) => void; deleteTrace: (startEdgeId: string) => void; // Archive actions toggleNodeDisabled: (nodeId: string) => void; archiveNode: (nodeId: string) => void; removeFromArchive: (archiveId: string) => void; createNodeFromArchive: (archiveId: string, position: { x: number; y: number }) => void; // Trace disable toggleTraceDisabled: (edgeId: string) => void; updateEdgeStyles: () => void; // Quick Chat helpers isTraceComplete: (trace: Trace) => boolean; getTraceNodeIds: (trace: Trace) => string[]; createQuickChatNode: ( fromNodeId: string, trace: Trace | null, userPrompt: string, response: string, model: string, config: Partial ) => string; // Returns new node ID // Blueprint serialization / persistence serializeBlueprint: (viewport?: ViewportState) => BlueprintDocument; loadBlueprint: (doc: BlueprintDocument) => ViewportState | undefined; saveBlueprintFile: (path: string, viewport?: ViewportState) => Promise; readBlueprintFile: (path: string) => Promise; refreshProjectTree: () => Promise; createProjectFolder: (path: string) => Promise; renameProjectItem: (path: string, newName?: string, newPath?: string) => Promise; deleteProjectItem: (path: string, isFolder?: boolean) => Promise; setCurrentBlueprintPath: (path?: string) => void; setLastViewport: (viewport: ViewportState) => void; saveCurrentBlueprint: (path?: string, viewport?: ViewportState) => Promise; clearBlueprint: () => void; loadArchivedNodes: () => Promise; saveArchivedNodes: () => Promise; refreshFiles: () => Promise; uploadFile: (file: File, options?: UploadFileOptions) => Promise; deleteFile: (fileId: string) => Promise; setFiles: (files: FileMeta[]) => void; setUploading: (ids: string[]) => void; addFileScope: (fileId: string, scope: string) => Promise; removeFileScope: (fileId: string, scope: string) => Promise; // Merge trace functions createMergedTrace: ( nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy ) => string; // Returns merged trace ID updateMergedTrace: ( nodeId: string, mergedTraceId: string, updates: { sourceTraceIds?: string[]; strategy?: MergeStrategy; summarizedContent?: string } ) => void; deleteMergedTrace: (nodeId: string, mergedTraceId: string) => void; computeMergedMessages: ( nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy, tracesOverride?: Trace[] ) => Message[]; propagateTraces: () => void; } // Hash string to color const getStableColor = (str: string) => { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } const hue = Math.abs(hash % 360); return `hsl(${hue}, 70%, 60%)`; }; import { useAuthStore } from './authStore'; const API_BASE = import.meta.env.VITE_BACKEND_URL || ''; const DEFAULT_USER = 'test'; // Fallback for unauthenticated requests // Get current username directly from authStore const getCurrentUser = () => { const authState = useAuthStore.getState(); return authState.user?.username || DEFAULT_USER; }; // Get auth headers directly from authStore const getAuthHeaders = (): Record => { const authState = useAuthStore.getState(); if (authState.token) { return { Authorization: `Bearer ${authState.token}` }; } return {}; }; const jsonFetch = async (url: string, options?: RequestInit): Promise => { const authHeaders = getAuthHeaders(); const res = await fetch(url, { ...options, headers: { ...options?.headers, ...authHeaders, }, }); if (!res.ok) { const detail = await res.text(); throw new Error(detail || `Request failed: ${res.status}`); } return res.json() as Promise; }; const useFlowStore = create((set, get) => { const validateBlueprint = (doc: any): BlueprintDocument => { if (!doc || typeof doc !== 'object') throw new Error('Invalid blueprint: not an object'); if (typeof doc.version !== 'number') throw new Error('Invalid blueprint: missing version'); if (!Array.isArray(doc.nodes) || !Array.isArray(doc.edges)) throw new Error('Invalid blueprint: nodes/edges missing'); return doc as BlueprintDocument; }; return { nodes: [], edges: [], selectedNodeId: null, archivedNodes: [], files: [], uploadingFileIds: [], theme: 'light' as const, projectTree: [], currentBlueprintPath: undefined, lastViewport: undefined, saveStatus: 'idle', toggleTheme: () => { const newTheme = get().theme === 'light' ? 'dark' : 'light'; set({ theme: newTheme }); // Update document class for global CSS if (newTheme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, setLastViewport: (viewport: ViewportState) => { set({ lastViewport: viewport }); }, setFiles: (files: FileMeta[]) => { set({ files }); }, setUploading: (ids: string[]) => { 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 padding = 20; let x = baseX; let y = baseY; let attempts = 0; const maxAttempts = 100; // Increase attempts 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 || testX > nodeX + nodeWidth + padding || testY + nodeHeight + padding < nodeY || testY > nodeY + nodeHeight + 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 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; // 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 = new Map(); const visited = new Set(); const queue: { id: string; level: number; index: number }[] = []; const horizontalSpacing = 350; const verticalSpacing = 150; // Initialize with root nodes rootNodes.forEach((node, index) => { queue.push({ id: node.id, level: 0, index }); visited.add(node.id); }); // Track nodes per level for vertical positioning const nodesPerLevel: Map = new Map(); while (queue.length > 0) { const { id, level, index } = 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 const outgoingEdges = edges.filter(e => e.source === id); outgoingEdges.forEach((edge, i) => { if (!visited.has(edge.target)) { visited.add(edge.target); queue.push({ id: edge.target, level: level + 1, index: i }); } }); } // Handle orphan nodes (not connected to anything) let orphanY = 100; nodes.forEach(node => { if (!nodePositions.has(node.id)) { nodePositions.set(node.id, { x: 100, y: orphanY }); orphanY += verticalSpacing; } }); // Apply positions set({ nodes: nodes.map(node => ({ ...node, position: nodePositions.get(node.id) || node.position })) }); }, onNodesChange: (changes: NodeChange[]) => { // Check if any nodes are being removed const hasRemovals = changes.some(c => c.type === 'remove'); set({ nodes: applyNodeChanges(changes, get().nodes) as LLMNode[], }); // If nodes were removed, also clean up related edges and propagate traces if (hasRemovals) { const removedIds = changes.filter(c => c.type === 'remove').map(c => c.id); set({ edges: get().edges.filter(e => !removedIds.includes(e.source) && !removedIds.includes(e.target)) }); get().propagateTraces(); } }, onEdgesChange: (changes: EdgeChange[]) => { set({ edges: applyEdgeChanges(changes, get().edges), }); get().propagateTraces(); }, onConnect: (connection: Connection) => { const { nodes, edges } = get(); const sourceNode = nodes.find(n => n.id === connection.source); if (!sourceNode) return; // Handle prepend connections - only allow from 'new-trace' handle // Prepend makes the source node become the NEW HEAD of an existing trace if (connection.targetHandle?.startsWith('prepend-')) { // Prepend connections MUST come from 'new-trace' handle if (connection.sourceHandle !== 'new-trace') { console.warn('Prepend connections only allowed from new-trace handle'); return; } const targetNode = nodes.find(n => n.id === connection.target); if (!targetNode) return; // Get the trace ID from the prepend handle - this is the trace we're joining const traceId = connection.targetHandle.replace('prepend-', '').replace('inherited-', ''); const regularTrace = targetNode.data.outgoingTraces?.find(t => t.id === traceId); const mergedTrace = targetNode.data.mergedTraces?.find(m => m.id === traceId); const traceColor = regularTrace?.color || (mergedTrace?.colors?.[0]) || getStableColor(traceId); // Instead of creating a new trace, we JOIN the existing trace // The source node (C) becomes the new head of the trace // We use the SAME trace ID so it's truly the same trace // Add this trace as a "forked trace" on the source node so it shows up as an output handle // But use the ORIGINAL trace ID (not a new prepend-xxx ID) const inheritedTrace: Trace = { id: traceId, // Use the SAME trace ID! sourceNodeId: sourceNode.id, // C is now the source/head color: traceColor, messages: [] // Will be populated by propagateTraces }; // Add to source node's forkedTraces get().updateNodeData(sourceNode.id, { forkedTraces: [...(sourceNode.data.forkedTraces || []), inheritedTrace] }); // Update target node's forkedTrace to mark it as "prepended" // by setting sourceNodeId to the new head (source node) const targetForked = targetNode.data.forkedTraces || []; const updatedForked = targetForked.map(t => t.id === traceId ? { ...t, sourceNodeId: sourceNode.id } : t ); if (JSON.stringify(updatedForked) !== JSON.stringify(targetForked)) { get().updateNodeData(targetNode.id, { forkedTraces: updatedForked }); } // Create the edge using the SAME trace ID // This connects C's output to A's prepend input set({ edges: addEdge({ ...connection, sourceHandle: `trace-${traceId}`, // Same trace ID! style: { stroke: traceColor, strokeWidth: 2 } }, get().edges), }); setTimeout(() => get().propagateTraces(), 0); return; } // Helper to trace back the path of a trace by following edges upstream 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 const pathNodes: string[] = [forkAtNodeId]; 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}` ); 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]; 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 const newEdges: Edge[] = []; // Track which input handles we're creating for new edges const newInputHandles: Map = new Map(); for (let i = 0; i < pathEdges.length; i++) { const originalEdge = pathEdges[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 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, target: toNodeId, sourceHandle: `trace-${newTraceId}`, targetHandle: `input-${nextInputIndex}`, style: { stroke: newTraceColor, strokeWidth: 2 } }); } // Find the messages up to the fork point 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 const newForkTrace: Trace = { id: newTraceId, sourceNodeId: firstNodeId, color: newTraceColor, messages: [...messagesUpToFork] }; get().updateNodeData(firstNodeId, { forkedTraces: [...(firstNode.data.forkedTraces || []), newForkTrace] }); 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(); 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 = 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 = () => { let originalTraceMessages: Message[] = []; if (connection.sourceHandle?.startsWith('trace-')) { const originalTraceId = connection.sourceHandle.replace('trace-', ''); const originalTrace = sourceNode.data.outgoingTraces?.find(t => t.id === originalTraceId); if (originalTrace) { originalTraceMessages = [...originalTrace.messages]; } } if (originalTraceMessages.length === 0) { if (sourceNode.data.userPrompt) { originalTraceMessages.push({ id: `${sourceNode.id}-u`, role: 'user', content: sourceNode.data.userPrompt }); } if (sourceNode.data.response) { originalTraceMessages.push({ id: `${sourceNode.id}-a`, role: 'assistant', content: sourceNode.data.response }); } } const newForkId = `fork-${sourceNode.id}-${Date.now()}`; const newForkTrace: Trace = { id: newForkId, sourceNodeId: sourceNode.id, color: getStableColor(newForkId), messages: originalTraceMessages }; get().updateNodeData(sourceNode.id, { forkedTraces: [...(sourceNode.data.forkedTraces || []), newForkTrace] }); return newForkTrace; }; // Check if connecting from "new-trace" handle - always create simple fork if (connection.sourceHandle === 'new-trace') { const newForkTrace = createSimpleForkTrace(); set({ edges: addEdge({ ...connection, sourceHandle: `trace-${newForkTrace.id}`, style: { stroke: newForkTrace.color, strokeWidth: 2 } }, get().edges), }); setTimeout(() => get().propagateTraces(), 0); return; } // Check if this trace handle already has a downstream connection const existingEdgeFromHandle = edges.find( e => e.source === connection.source && e.sourceHandle === connection.sourceHandle ); if (existingEdgeFromHandle && connection.sourceHandle?.startsWith('trace-')) { 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) { const { newTraceId, newEdges } = duplicateResult; const newTraceColor = getStableColor(newTraceId); set({ edges: [ ...get().edges, ...newEdges, { id: `edge-${connection.source}-${connection.target}-${Date.now()}`, source: connection.source!, target: connection.target!, sourceHandle: `trace-${newTraceId}`, targetHandle: connection.targetHandle, style: { stroke: newTraceColor, strokeWidth: 2 } } as Edge ], }); setTimeout(() => get().propagateTraces(), 0); return; } else { const newForkTrace = createSimpleForkTrace(); set({ edges: addEdge({ ...connection, sourceHandle: `trace-${newForkTrace.id}`, style: { stroke: newForkTrace.color, strokeWidth: 2 } }, get().edges), }); setTimeout(() => get().propagateTraces(), 0); return; } } // Normal connection - no existing edge from this handle set({ edges: addEdge({ ...connection, style: { stroke: '#888', strokeWidth: 2 } }, get().edges), }); setTimeout(() => get().propagateTraces(), 0); }, addNode: (node: LLMNode) => { set((state) => ({ nodes: [...state.nodes, node] })); setTimeout(() => get().propagateTraces(), 0); }, updateNodeData: (nodeId: string, data: Partial) => { set((state) => ({ nodes: state.nodes.map((node) => { if (node.id === nodeId) { return { ...node, data: { ...node.data, ...data } }; } return node; }), })); // Only propagate traces when response changes (affects downstream context) // Do NOT propagate on userPrompt changes to avoid resetting activeTraceIds during typing if (data.response !== undefined) { get().propagateTraces(); } }, setSelectedNode: (nodeId: string | null) => { set({ selectedNodeId: nodeId }); }, getActiveContext: (nodeId: string) => { const node = get().nodes.find(n => n.id === nodeId); if (!node) return []; const activeIds = node.data.activeTraceIds || []; if (activeIds.length === 0) return []; // Collect all traces by ID to avoid duplicates const tracesById = new Map(); // Add incoming traces (node.data.traces || []).forEach((t: Trace) => { if (activeIds.includes(t.id)) { tracesById.set(t.id, t); } }); // Add outgoing traces (only if not already in incoming) (node.data.outgoingTraces || []).forEach((t: Trace) => { if (activeIds.includes(t.id) && !tracesById.has(t.id)) { tracesById.set(t.id, t); } }); // Collect messages from selected traces const contextMessages: Message[] = []; const nodePrefix = `${nodeId}-`; tracesById.forEach((t: Trace) => { // For traces originated by this node, filter out this node's own messages const isOriginated = t.id === `trace-${nodeId}` || t.id.startsWith('fork-') || (t.id.startsWith('prepend-') && t.id.includes(`-from-${nodeId}`)); if (isOriginated) { // Only include prepended upstream messages const prependedMessages = t.messages.filter(m => !m.id?.startsWith(nodePrefix)); contextMessages.push(...prependedMessages); } else { // Include all messages for incoming traces contextMessages.push(...t.messages); } }); // Check merged traces const activeMerged = (node.data.mergedTraces || []).filter((m: MergedTrace) => activeIds.includes(m.id) ); activeMerged.forEach((m: MergedTrace) => { contextMessages.push(...m.messages); }); return contextMessages; }, deleteEdge: (edgeId: string) => { set({ edges: get().edges.filter(e => e.id !== edgeId) }); get().propagateTraces(); }, deleteNode: (nodeId: string) => { set({ nodes: get().nodes.filter(n => n.id !== nodeId), edges: get().edges.filter(e => e.source !== nodeId && e.target !== nodeId) }); get().propagateTraces(); }, deleteBranch: (startNodeId?: string, startEdgeId?: string) => { const { edges, nodes } = get(); // We ONLY delete edges, NOT nodes. const edgesToDelete = new Set(); // Helper to traverse downstream EDGES based on Trace Dependency 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}`; 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}`) { traverse(nextEdge); } }); }; if (startNodeId) { // If deleting a node, we delete ALL outgoing edges recursively. // Because all traces passing through this node are broken. // But we can't use `traverse` directly because we don't have a single start edge. // We just start traverse on ALL outgoing edges of this node. const initialOutgoing = edges.filter(e => e.source === startNodeId); initialOutgoing.forEach(e => traverse(e)); // Also delete incoming to this node const incomingToNode = edges.filter(e => e.target === startNodeId); incomingToNode.forEach(e => edgesToDelete.add(e.id)); set({ nodes: nodes.filter(n => n.id !== startNodeId), edges: edges.filter(e => !edgesToDelete.has(e.id)) }); } else if (startEdgeId) { const startEdge = edges.find(e => e.id === startEdgeId); if (startEdge) { traverse(startEdge); } set({ edges: edges.filter(e => !edgesToDelete.has(e.id)) }); } get().propagateTraces(); }, deleteTrace: (startEdgeId: string) => { const { edges, nodes } = get(); // Delete edges along the trace AND orphaned nodes (nodes with no remaining connections) const edgesToDelete = new Set(); const nodesInTrace = new Set(); // Helper to traverse downstream EDGES based on Trace Dependency 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}`; const outgoing = edges.filter(e => e.source === targetNodeId); outgoing.forEach(nextEdge => { if (nextEdge.sourceHandle === `trace-${expectedNextTraceId}`) { traverse(nextEdge); } }); }; // Also traverse backwards to find upstream 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); } }); } }; const startEdge = edges.find(e => e.id === startEdgeId); if (startEdge) { // Traverse forward traverse(startEdge); // Traverse backward traverseBackward(startEdge); } // Filter remaining edges after deletion const remainingEdges = edges.filter(e => !edgesToDelete.has(e.id)); // Find nodes that become orphaned (no connections at all after edge deletion) const nodesToDelete = new Set(); nodesInTrace.forEach(nodeId => { const hasRemainingEdges = remainingEdges.some( e => e.source === nodeId || e.target === nodeId ); if (!hasRemainingEdges) { nodesToDelete.add(nodeId); } }); set({ nodes: nodes.filter(n => !nodesToDelete.has(n.id)), edges: remainingEdges }); get().propagateTraces(); }, toggleNodeDisabled: (nodeId: string) => { const node = get().nodes.find(n => n.id === nodeId); if (node) { const newDisabled = !node.data.disabled; // Update node data AND draggable property set(state => ({ nodes: state.nodes.map(n => { if (n.id === nodeId) { return { ...n, draggable: !newDisabled, // Disable dragging when node is disabled selectable: !newDisabled, // Disable selection when node is disabled data: { ...n.data, disabled: newDisabled } }; } return n; }) })); // Update edge styles to reflect disabled state setTimeout(() => get().updateEdgeStyles(), 0); } }, archiveNode: (nodeId: string) => { const node = get().nodes.find(n => n.id === nodeId); if (!node) return; const archived: ArchivedNode = { id: `archive_${Date.now()}`, label: node.data.label, model: node.data.model, systemPrompt: node.data.systemPrompt, temperature: node.data.temperature, reasoningEffort: node.data.reasoningEffort || 'medium', userPrompt: node.data.userPrompt, response: node.data.response, enableGoogleSearch: node.data.enableGoogleSearch, mergeStrategy: node.data.mergeStrategy, attachedFileIds: node.data.attachedFileIds || [] }; set(state => ({ archivedNodes: [...state.archivedNodes, archived] })); setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); }, removeFromArchive: (archiveId: string) => { set(state => ({ archivedNodes: state.archivedNodes.filter(a => a.id !== archiveId) })); setTimeout(() => get().saveArchivedNodes().catch(() => {}), 0); }, createNodeFromArchive: (archiveId: string, position: { x: number; y: number }) => { const archived = get().archivedNodes.find(a => a.id === archiveId); if (!archived) return; const newNode: LLMNode = { id: `node_${Date.now()}`, type: 'llmNode', position, data: { label: archived.label, model: archived.model, temperature: archived.temperature, systemPrompt: archived.systemPrompt, userPrompt: archived.userPrompt || '', reasoningEffort: archived.reasoningEffort, enableGoogleSearch: archived.enableGoogleSearch, mergeStrategy: archived.mergeStrategy || 'smart', traces: [], outgoingTraces: [], forkedTraces: [], mergedTraces: [], activeTraceIds: [], attachedFileIds: archived.attachedFileIds || [], response: archived.response || '', status: 'idle', inputs: 1 } }; get().addNode(newNode); }, toggleTraceDisabled: (edgeId: string) => { const { edges, nodes } = get(); const edge = edges.find(e => e.id === edgeId); if (!edge) return; // Find all nodes connected through this trace (BIDIRECTIONAL) const nodesInTrace = new Set(); const visitedEdges = new Set(); // Traverse downstream (source -> target direction) const traverseDownstream = (currentNodeId: string) => { nodesInTrace.add(currentNodeId); const outgoing = edges.filter(e => e.source === currentNodeId); outgoing.forEach(nextEdge => { if (visitedEdges.has(nextEdge.id)) return; visitedEdges.add(nextEdge.id); traverseDownstream(nextEdge.target); }); }; // Traverse upstream (target -> source direction) const traverseUpstream = (currentNodeId: string) => { nodesInTrace.add(currentNodeId); const incoming = edges.filter(e => e.target === currentNodeId); incoming.forEach(prevEdge => { if (visitedEdges.has(prevEdge.id)) return; visitedEdges.add(prevEdge.id); traverseUpstream(prevEdge.source); }); }; // Start bidirectional traversal from clicked edge visitedEdges.add(edge.id); // Go upstream from source (including source itself) traverseUpstream(edge.source); // Go downstream from target (including target itself) traverseDownstream(edge.target); // Check if any node in this trace is disabled const anyDisabled = Array.from(nodesInTrace).some( nodeId => nodes.find(n => n.id === nodeId)?.data.disabled ); // Toggle: if any disabled -> enable all, else disable all const newDisabledState = !anyDisabled; set(state => ({ nodes: state.nodes.map(node => { if (nodesInTrace.has(node.id)) { return { ...node, draggable: !newDisabledState, selectable: !newDisabledState, data: { ...node.data, disabled: newDisabledState } }; } return node; }) })); // Update edge styles get().updateEdgeStyles(); }, updateEdgeStyles: () => { const { nodes, edges } = get(); const updatedEdges = edges.map(edge => { const sourceNode = nodes.find(n => n.id === edge.source); const targetNode = nodes.find(n => n.id === edge.target); const isDisabled = sourceNode?.data.disabled || targetNode?.data.disabled; return { ...edge, style: { ...edge.style, opacity: isDisabled ? 0.3 : 1, strokeDasharray: isDisabled ? '5,5' : undefined } }; }); set({ edges: updatedEdges }); }, // Check if all nodes in trace path have complete Q&A isTraceComplete: (trace: Trace) => { // A trace is complete if all nodes in the path have complete Q&A pairs const messages = trace.messages; if (messages.length === 0) { // Empty trace - check if the source node has content const { nodes } = get(); const sourceNode = nodes.find(n => n.id === trace.sourceNodeId); if (sourceNode) { return !!sourceNode.data.userPrompt && !!sourceNode.data.response; } return true; // No source node, assume complete } // Extract node IDs from message IDs (format: nodeId-user or nodeId-assistant) // Group messages by node and check each node has both user and assistant const nodeMessages = new Map(); for (const msg of messages) { if (!msg.id) continue; // Parse nodeId from message ID (format: nodeId-user or nodeId-assistant) const parts = msg.id.split('-'); if (parts.length < 2) continue; // The nodeId is everything except the last part (user/assistant) const lastPart = parts[parts.length - 1]; const nodeId = lastPart === 'user' || lastPart === 'assistant' ? parts.slice(0, -1).join('-') : msg.id; // Fallback: use whole ID if format doesn't match if (!nodeMessages.has(nodeId)) { nodeMessages.set(nodeId, { hasUser: false, hasAssistant: false }); } const nodeData = nodeMessages.get(nodeId)!; if (msg.role === 'user') nodeData.hasUser = true; if (msg.role === 'assistant') nodeData.hasAssistant = true; } // Check that ALL nodes in the trace have both user and assistant messages for (const [nodeId, data] of nodeMessages) { if (!data.hasUser || !data.hasAssistant) { return false; // This node is incomplete } } // Must have at least one complete node return nodeMessages.size > 0; }, // Get all node IDs in trace path getTraceNodeIds: (trace: Trace) => { const traceId = trace.id; const parts = traceId.replace('trace-', '').split('_'); return parts.filter(p => p.startsWith('node') || get().nodes.some(n => n.id === p)); }, // Create a new node for quick chat, with proper connection createQuickChatNode: ( fromNodeId: string, trace: Trace | null, userPrompt: string, response: string, model: string, config: Partial ) => { const { nodes, edges, addNode, updateNodeData } = get(); const fromNode = nodes.find(n => n.id === fromNodeId); if (!fromNode) return ''; // Check if current node is empty (no response) -> overwrite it const isCurrentNodeEmpty = !fromNode.data.response; if (isCurrentNodeEmpty) { // Overwrite current node updateNodeData(fromNodeId, { userPrompt, response, model, status: 'success', querySentAt: Date.now(), responseReceivedAt: Date.now(), ...config }); return fromNodeId; } // Create new node to the right // Use findNonOverlappingPosition to avoid collision, starting from the ideal position const idealX = fromNode.position.x + 300; const idealY = fromNode.position.y; // Check if ideal position overlaps const { findNonOverlappingPosition } = get(); const newPos = findNonOverlappingPosition(idealX, idealY); const newNodeId = `node_${Date.now()}`; const newNode: LLMNode = { id: newNodeId, type: 'llmNode', position: newPos, data: { label: 'Quick Chat', model, temperature: config.temperature || 0.7, systemPrompt: '', userPrompt, mergeStrategy: 'smart', reasoningEffort: config.reasoningEffort || 'medium', enableGoogleSearch: config.enableGoogleSearch, traces: [], outgoingTraces: [], forkedTraces: [], mergedTraces: [], activeTraceIds: [], attachedFileIds: [], response, status: 'success', inputs: 1, querySentAt: Date.now(), responseReceivedAt: Date.now(), ...config } }; addNode(newNode); // Connect from the source node using new-trace handle (creates a fork) setTimeout(() => { const store = useFlowStore.getState(); store.onConnect({ source: fromNodeId, sourceHandle: 'new-trace', target: newNodeId, targetHandle: 'input-0' }); }, 50); return newNodeId; }, // -------- Blueprint serialization / persistence -------- setCurrentBlueprintPath: (path?: string) => { set({ currentBlueprintPath: path }); }, serializeBlueprint: (viewport?: ViewportState): BlueprintDocument => { return { version: 1, nodes: get().nodes, edges: get().edges, viewport: viewport || get().lastViewport, theme: get().theme, }; }, loadBlueprint: (doc: BlueprintDocument): ViewportState | undefined => { set({ nodes: (doc.nodes || []) as LLMNode[], edges: (doc.edges || []) as Edge[], theme: doc.theme || get().theme, selectedNodeId: null, lastViewport: doc.viewport || get().lastViewport, }); // Recompute traces after loading setTimeout(() => get().propagateTraces(), 0); return doc.viewport; }, 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({ user: getCurrentUser(), path, content: payload, }), }); set({ currentBlueprintPath: path, lastViewport: payload.viewport }); await get().refreshProjectTree(); }, readBlueprintFile: async (path: string): Promise => { const res = await jsonFetch<{ content: BlueprintDocument }>( `${API_BASE}/api/projects/file?user=${encodeURIComponent(getCurrentUser())}&path=${encodeURIComponent(path)}` ); return validateBlueprint(res.content); }, refreshProjectTree: async () => { const tree = await jsonFetch( `${API_BASE}/api/projects/tree?user=${encodeURIComponent(getCurrentUser())}` ); set({ projectTree: tree }); return tree; }, createProjectFolder: async (path: string) => { await jsonFetch(`${API_BASE}/api/projects/create_folder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: DEFAULT_USER, path }), }); await get().refreshProjectTree(); }, renameProjectItem: async (path: string, newName?: string, newPath?: string) => { if (!newName && !newPath) { throw new Error('newName or newPath is required'); } await jsonFetch(`${API_BASE}/api/projects/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: DEFAULT_USER, path, new_name: newName, new_path: newPath }), }); await get().refreshProjectTree(); }, deleteProjectItem: async (path: string, isFolder = false) => { await jsonFetch(`${API_BASE}/api/projects/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: DEFAULT_USER, path, is_folder: isFolder }), }); await get().refreshProjectTree(); }, saveCurrentBlueprint: async (path?: string, viewport?: ViewportState) => { const targetPath = path || get().currentBlueprintPath; if (!targetPath) { throw new Error('No blueprint path. Please provide a file name.'); } set({ saveStatus: 'saving' }); try { await get().saveBlueprintFile(targetPath, viewport); set({ saveStatus: 'saved', currentBlueprintPath: targetPath }); } catch (e) { console.error(e); set({ saveStatus: 'error' }); throw e; } }, clearBlueprint: () => { set({ nodes: [], edges: [], selectedNodeId: null, currentBlueprintPath: undefined, lastViewport: undefined, saveStatus: 'idle', }); }, loadArchivedNodes: async () => { const res = await jsonFetch<{ archived: ArchivedNode[] }>( `${API_BASE}/api/projects/archived?user=${encodeURIComponent(getCurrentUser())}` ); set({ archivedNodes: res.archived || [] }); }, saveArchivedNodes: async () => { const payload = { user: DEFAULT_USER, archived: get().archivedNodes }; await jsonFetch(`${API_BASE}/api/projects/archived`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }, // Files management refreshFiles: async () => { const res = await jsonFetch<{ files: FileMeta[] }>( `${API_BASE}/api/files?user=${encodeURIComponent(getCurrentUser())}` ); set({ files: res.files || [] }); }, uploadFile: async (file: File, options?: UploadFileOptions) => { const provider = options?.provider ?? 'local'; const purpose = options?.purpose; const tempId = `${file.name}-${Date.now()}`; const prev = get().uploadingFileIds || []; set({ uploadingFileIds: [...prev, tempId] }); const form = new FormData(); form.append('file', file); form.append('provider', provider); if (purpose) { form.append('purpose', purpose); } try { const res = await fetch(`${API_BASE}/api/files/upload?user=${encodeURIComponent(getCurrentUser())}`, { method: 'POST', body: form, }); if (!res.ok) { throw new Error(await res.text()); } const data = await res.json(); if (!data.file) { throw new Error('Upload succeeded but no file info returned'); } await get().refreshFiles(); return data.file as FileMeta; } finally { set({ uploadingFileIds: (get().uploadingFileIds || []).filter(id => id !== tempId) }); } }, deleteFile: async (fileId: string) => { const res = await fetch(`${API_BASE}/api/files/delete?user=${encodeURIComponent(getCurrentUser())}&file_id=${encodeURIComponent(fileId)}`, { method: 'POST', }); if (!res.ok) { throw new Error(await res.text()); } await get().refreshFiles(); }, addFileScope: async (fileId: string, scope: string) => { const res = await fetch(`${API_BASE}/api/files/add_scope`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: DEFAULT_USER, file_id: fileId, scope }), }); if (!res.ok) { throw new Error(await res.text()); } await get().refreshFiles(); }, removeFileScope: async (fileId: string, scope: string) => { const res = await fetch(`${API_BASE}/api/files/remove_scope`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: DEFAULT_USER, file_id: fileId, scope }), }); if (!res.ok) { throw new Error(await res.text()); } await get().refreshFiles(); }, // -------------------------------------------------------- // Compute merged messages based on strategy // Optional tracesOverride parameter to use latest traces during propagation computeMergedMessages: (nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy, tracesOverride?: Trace[]): Message[] => { const { nodes } = get(); const node = nodes.find(n => n.id === nodeId); if (!node) return []; // Use override traces if provided (for propagation), otherwise use node's stored traces const availableTraces = tracesOverride || node.data.traces || []; // Get the source traces const sourceTraces = sourceTraceIds .map(id => availableTraces.find((t: Trace) => t.id === id)) .filter((t): t is Trace => t !== undefined); if (sourceTraces.length === 0) return []; // Helper to add source trace info to a message const tagMessage = (msg: Message, trace: Trace): Message => ({ ...msg, sourceTraceId: trace.id, sourceTraceColor: trace.color }); // Helper to get timestamp for a message based on node const getMessageTimestamp = (msg: Message, type: 'query' | 'response'): number => { // Extract node ID from message ID (format: nodeId-user or nodeId-assistant) const msgNodeId = msg.id?.split('-')[0]; if (!msgNodeId) return 0; const msgNode = nodes.find(n => n.id === msgNodeId); if (!msgNode) return 0; if (type === 'query') { return msgNode.data.querySentAt || 0; } else { return msgNode.data.responseReceivedAt || 0; } }; // Helper to get all messages with their timestamps and trace info const getAllMessagesWithTime = () => { const allMessages: { msg: Message; queryTime: number; responseTime: number; trace: Trace }[] = []; sourceTraces.forEach((trace) => { trace.messages.forEach(msg => { allMessages.push({ msg: tagMessage(msg, trace), queryTime: getMessageTimestamp(msg, 'query'), responseTime: getMessageTimestamp(msg, 'response'), trace }); }); }); return allMessages; }; switch (strategy) { case 'query_time': { // Sort by query time, keeping Q-A pairs together const pairs: { user: Message | null; assistant: Message | null; time: number; trace: Trace }[] = []; sourceTraces.forEach(trace => { for (let i = 0; i < trace.messages.length; i += 2) { const user = trace.messages[i]; const assistant = trace.messages[i + 1] || null; const time = getMessageTimestamp(user, 'query'); pairs.push({ user: user ? tagMessage(user, trace) : null, assistant: assistant ? tagMessage(assistant, trace) : null, time, trace }); } }); pairs.sort((a, b) => a.time - b.time); const result: Message[] = []; pairs.forEach(pair => { if (pair.user) result.push(pair.user); if (pair.assistant) result.push(pair.assistant); }); return result; } case 'response_time': { // Sort by response time, keeping Q-A pairs together const pairs: { user: Message | null; assistant: Message | null; time: number; trace: Trace }[] = []; sourceTraces.forEach(trace => { for (let i = 0; i < trace.messages.length; i += 2) { const user = trace.messages[i]; const assistant = trace.messages[i + 1] || null; const time = getMessageTimestamp(assistant || user, 'response'); pairs.push({ user: user ? tagMessage(user, trace) : null, assistant: assistant ? tagMessage(assistant, trace) : null, time, trace }); } }); pairs.sort((a, b) => a.time - b.time); const result: Message[] = []; pairs.forEach(pair => { if (pair.user) result.push(pair.user); if (pair.assistant) result.push(pair.assistant); }); return result; } case 'trace_order': { // Simply concatenate in the order of sourceTraceIds const result: Message[] = []; sourceTraces.forEach(trace => { trace.messages.forEach(msg => { result.push(tagMessage(msg, trace)); }); }); return result; } case 'grouped': { // Same as trace_order but more explicitly grouped const result: Message[] = []; sourceTraces.forEach(trace => { trace.messages.forEach(msg => { result.push(tagMessage(msg, trace)); }); }); return result; } case 'interleaved': { // True interleaving - sort ALL messages by their actual time const allMessages = getAllMessagesWithTime(); // Sort by the earlier of query/response time for each message allMessages.sort((a, b) => { const aTime = a.msg.role === 'user' ? a.queryTime : a.responseTime; const bTime = b.msg.role === 'user' ? b.queryTime : b.responseTime; return aTime - bTime; }); return allMessages.map(m => m.msg); } case 'summary': { // For summary, we return all messages in trace order with source tags // The actual summarization is done asynchronously and stored in summarizedContent const result: Message[] = []; sourceTraces.forEach(trace => { trace.messages.forEach(msg => { result.push(tagMessage(msg, trace)); }); }); return result; } default: return []; } }, // Create a new merged trace createMergedTrace: (nodeId: string, sourceTraceIds: string[], strategy: MergeStrategy): string => { const { nodes, updateNodeData, computeMergedMessages } = get(); const node = nodes.find(n => n.id === nodeId); if (!node) return ''; // Get colors from source traces (preserve multi-colors for merged parents) const colors = sourceTraceIds.flatMap(id => { const t = node.data.traces.find((tr: Trace) => tr.id === id); if (!t) return []; if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; return t.color ? [t.color] : []; }); // Compute merged messages const messages = computeMergedMessages(nodeId, sourceTraceIds, strategy); const mergedTraceId = `merged-${nodeId}-${Date.now()}`; const mergedTrace: MergedTrace = { id: mergedTraceId, sourceNodeId: nodeId, sourceTraceIds, strategy, colors, messages }; const existingMerged = node.data.mergedTraces || []; updateNodeData(nodeId, { mergedTraces: [...existingMerged, mergedTrace] }); // Trigger trace propagation to update outgoing traces setTimeout(() => { get().propagateTraces(); }, 50); return mergedTraceId; }, // Update an existing merged trace updateMergedTrace: (nodeId: string, mergedTraceId: string, updates: { sourceTraceIds?: string[]; strategy?: MergeStrategy; summarizedContent?: string }) => { const { nodes, updateNodeData, computeMergedMessages } = get(); const node = nodes.find(n => n.id === nodeId); if (!node) return; const existingMerged = node.data.mergedTraces || []; const mergedIndex = existingMerged.findIndex((m: MergedTrace) => m.id === mergedTraceId); if (mergedIndex === -1) return; const current = existingMerged[mergedIndex]; const newSourceTraceIds = updates.sourceTraceIds || current.sourceTraceIds; const newStrategy = updates.strategy || current.strategy; // Recompute colors if source traces changed (preserve multi-colors) let newColors = current.colors; if (updates.sourceTraceIds) { newColors = updates.sourceTraceIds.flatMap(id => { const t = node.data.traces.find((tr: Trace) => tr.id === id); if (!t) return []; if (t.mergedColors && t.mergedColors.length > 0) return t.mergedColors; return t.color ? [t.color] : []; }); } // Recompute messages if source or strategy changed let newMessages = current.messages; if (updates.sourceTraceIds || updates.strategy) { newMessages = computeMergedMessages(nodeId, newSourceTraceIds, newStrategy); } const updatedMerged = [...existingMerged]; updatedMerged[mergedIndex] = { ...current, sourceTraceIds: newSourceTraceIds, strategy: newStrategy, colors: newColors, messages: newMessages, summarizedContent: updates.summarizedContent !== undefined ? updates.summarizedContent : current.summarizedContent }; updateNodeData(nodeId, { mergedTraces: updatedMerged }); // Trigger trace propagation setTimeout(() => { get().propagateTraces(); }, 50); }, // Delete a merged trace deleteMergedTrace: (nodeId: string, mergedTraceId: string) => { const { nodes, updateNodeData } = get(); const node = nodes.find(n => n.id === nodeId); if (!node) return; const existingMerged = node.data.mergedTraces || []; const filteredMerged = existingMerged.filter((m: MergedTrace) => m.id !== mergedTraceId); updateNodeData(nodeId, { mergedTraces: filteredMerged }); // Trigger trace propagation setTimeout(() => { get().propagateTraces(); }, 50); }, propagateTraces: () => { const { nodes, edges } = get(); // We need to calculate traces for each node, AND update edge colors. // Topological Sort const inDegree = new Map(); const graph = new Map(); nodes.forEach(node => { inDegree.set(node.id, 0); graph.set(node.id, []); }); edges.forEach(edge => { inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1); graph.get(edge.source)?.push(edge.target); }); const topoQueue: string[] = []; inDegree.forEach((count, id) => { if (count === 0) topoQueue.push(id); }); const sortedNodes: string[] = []; while (topoQueue.length > 0) { const u = topoQueue.shift()!; sortedNodes.push(u); const children = graph.get(u) || []; children.forEach(v => { inDegree.set(v, (inDegree.get(v) || 0) - 1); if (inDegree.get(v) === 0) { topoQueue.push(v); } }); } // Map: Traces LEAVING this node const nodeOutgoingTraces = new Map(); // Map: Traces ENTERING this node (to update NodeData) const nodeIncomingTraces = new Map(); // Map: Merged traces to delete from each node (disconnected sources) const nodeMergedTracesToDelete = new Map(); // Map>: Updated merged traces with new messages const nodeUpdatedMergedTraces = new Map>(); // Map: Prepend messages for each node (for recursive prepend chains) const nodePrependMessages = new Map(); // Map: Forked traces to keep (cleanup unused ones) const nodeForkedTracesToClean = new Map(); // Also track Edge updates (Color AND SourceHandle) const updatedEdges = [...edges]; let edgesChanged = false; // Iterate sortedNodes.forEach(nodeId => { const node = nodes.find(n => n.id === nodeId); if (!node) return; // 1. Gather Incoming Traces (regular and prepend) const incomingEdges = edges.filter(e => e.target === nodeId); const myIncomingTraces: Trace[] = []; // Map: traceIdToPrepend -> Message[] (messages to prepend to that trace) const prependMessages = new Map(); incomingEdges.forEach(edge => { const parentOutgoing = nodeOutgoingTraces.get(edge.source) || []; const targetHandle = edge.targetHandle || ''; // Check if this is a prepend handle const isPrependHandle = targetHandle.startsWith('prepend-'); // Special case: prepend connection (from a prepend trace created by onConnect) if (isPrependHandle) { // Find the source trace - it could be: // 1. A prepend trace (prepend-xxx-from-yyy format) // 2. Or any trace from the source handle let matchedPrependTrace = parentOutgoing.find(t => edge.sourceHandle === `trace-${t.id}`); // Fallback: if no exact match, find by sourceHandle pattern if (!matchedPrependTrace && edge.sourceHandle?.startsWith('trace-')) { const sourceTraceId = edge.sourceHandle.replace('trace-', ''); matchedPrependTrace = parentOutgoing.find(t => t.id === sourceTraceId); } // Extract the original target trace ID from the prepend handle // Format is "prepend-{traceId}" where traceId could be "fork-xxx" or "trace-xxx" const targetTraceId = targetHandle.replace('prepend-', '').replace('inherited-', ''); if (matchedPrependTrace && matchedPrependTrace.messages.length > 0) { // Prepend the messages from the source trace const existing = prependMessages.get(targetTraceId) || []; prependMessages.set(targetTraceId, [...existing, ...matchedPrependTrace.messages]); // Update edge color to match the trace const edgeIndex = updatedEdges.findIndex(e => e.id === edge.id); if (edgeIndex !== -1) { const currentEdge = updatedEdges[edgeIndex]; if (currentEdge.style?.stroke !== matchedPrependTrace.color) { updatedEdges[edgeIndex] = { ...currentEdge, style: { ...currentEdge.style, stroke: matchedPrependTrace.color, strokeWidth: 2 } }; } } } else { // Even if no messages yet, store the prepend trace info for later updates // The prepend connection should still work when the source node gets content const existing = prependMessages.get(targetTraceId) || []; if (!prependMessages.has(targetTraceId)) { prependMessages.set(targetTraceId, existing); } } return; // Don't process prepend edges as regular incoming traces } // Find match based on Handle ID let matchedTrace = parentOutgoing.find(t => edge.sourceHandle === `trace-${t.id}`); // If no exact match, try to find a "Semantic Match" (Auto-Reconnect) if (!matchedTrace && edge.sourceHandle?.startsWith('trace-')) { const oldId = edge.sourceHandle.replace('trace-', ''); matchedTrace = parentOutgoing.find(t => t.id === `${oldId}_${edge.source}`); } // Fallback: If still no match, and parent has traces, try to connect to the most logical one. if (!matchedTrace && parentOutgoing.length > 0) { if (!edge.sourceHandle) { matchedTrace = parentOutgoing[parentOutgoing.length - 1]; } } if (matchedTrace) { if (isPrependHandle) { // This is a prepend connection from a trace handle - extract the target trace ID const targetTraceId = targetHandle.replace('prepend-', '').replace('inherited-', ''); const existing = prependMessages.get(targetTraceId) || []; prependMessages.set(targetTraceId, [...existing, ...matchedTrace.messages]); } else { // Regular incoming trace myIncomingTraces.push(matchedTrace); } // Update Edge Visuals & Logical Connection const edgeIndex = updatedEdges.findIndex(e => e.id === edge.id); if (edgeIndex !== -1) { const currentEdge = updatedEdges[edgeIndex]; 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-'); 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 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 (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 // 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, type: isMergedTrace ? 'merged' : undefined, style: { ...currentEdge.style, stroke: matchedTrace.color, strokeWidth: 2 }, data: { ...currentEdge.data, gradient, isMerged: isMergedTrace, colors: finalColors } }; edgesChanged = true; } } } }); // Deduplicate incoming traces by ID (in case multiple edges carry same trace) const uniqueIncoming = Array.from(new Map(myIncomingTraces.map(t => [t.id, t])).values()); nodeIncomingTraces.set(nodeId, uniqueIncoming); // 2. Generate Outgoing Traces // Every incoming trace gets appended with this node's response. // PLUS, we always generate a "Self Trace" (Start New) that starts here. const myResponseMsg: Message[] = []; if (node.data.userPrompt) { myResponseMsg.push({ id: `${node.id}-user`, // Deterministic ID for stability role: 'user', content: node.data.userPrompt }); } if (node.data.response) { myResponseMsg.push({ id: `${node.id}-assistant`, role: 'assistant', content: node.data.response }); } const myOutgoingTraces: Trace[] = []; // A. Pass-through traces (append history) - only if there's a downstream edge uniqueIncoming.forEach(t => { // SIMPLIFICATION: Keep the same Trace ID for pass-through traces. // This ensures A-B-C appears as a single continuous trace with the same ID. // Only branch/fork traces get new IDs. const passThroughId = t.id; // Only create pass-through if there's actually a downstream edge using it const hasDownstreamEdge = updatedEdges.some(e => e.source === node.id && (e.sourceHandle === `trace-${passThroughId}`) ); if (hasDownstreamEdge) { myOutgoingTraces.push({ ...t, id: passThroughId, // Keep original sourceNodeId - this is a pass-through, not originated here sourceNodeId: t.sourceNodeId, messages: [...t.messages, ...myResponseMsg] }); } }); // B. Self Trace (New Branch) -> Only create if there's a downstream edge using it const selfTraceId = `trace-${node.id}`; const selfPrepend = prependMessages.get(selfTraceId) || []; // Store prepend messages for this node (for recursive prepend chains) // This includes any prepend from upstream and this node's own messages if (selfPrepend.length > 0 || myResponseMsg.length > 0) { nodePrependMessages.set(node.id, [...selfPrepend, ...myResponseMsg]); } // Only add self trace to outgoing if there's actually a downstream edge using it // Check if any edge uses this self trace as source // Edge sourceHandle format: "trace-{traceId}" where traceId = "trace-{nodeId}" // So sourceHandle would be "trace-trace-{nodeId}" const hasSelfTraceEdge = updatedEdges.some(e => e.source === node.id && (e.sourceHandle === `trace-${selfTraceId}` || e.sourceHandle === selfTraceId) ); if (hasSelfTraceEdge) { const selfTrace: Trace = { id: selfTraceId, sourceNodeId: node.id, color: getStableColor(node.id), messages: [...selfPrepend, ...myResponseMsg] }; myOutgoingTraces.push(selfTrace); } // C. Manual Forks - only include forks that have downstream edges if (node.data.forkedTraces) { // Filter to only forks that are actually being used (have edges) const activeForks = node.data.forkedTraces.filter(fork => { return updatedEdges.some(e => e.source === node.id && e.sourceHandle === `trace-${fork.id}` ); }); // Update messages for active forks const updatedForks = activeForks.map(fork => { const forkPrepend = prependMessages.get(fork.id) || []; return { ...fork, messages: [...forkPrepend, ...myResponseMsg] // Prepend + current messages }; }); myOutgoingTraces.push(...updatedForks); // Clean up unused forks from node data if (activeForks.length !== node.data.forkedTraces.length) { nodeForkedTracesToClean.set(nodeId, activeForks); } } // D. Merged Traces - recompute messages when source traces change // Also check if any source traces are disconnected, and mark for deletion const mergedTracesToDelete: string[] = []; const updatedMergedMap = new Map(); if (node.data.mergedTraces && node.data.mergedTraces.length > 0) { const { computeMergedMessages } = get(); node.data.mergedTraces.forEach((merged: MergedTrace) => { // Check if all source traces are still connected 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); if (!t) return []; 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, sourceNodeId: node.id, color: updatedColors[0] || getStableColor(merged.id), messages: mergedMessages, isMerged: true, mergedColors: updatedColors, sourceTraceIds: merged.sourceTraceIds }; myOutgoingTraces.push(mergedOutgoing); }); } // Store merged traces to delete for this node if (mergedTracesToDelete.length > 0) { nodeMergedTracesToDelete.set(nodeId, mergedTracesToDelete); } // Store updated merged trace data for this node if (updatedMergedMap.size > 0) { nodeUpdatedMergedTraces.set(nodeId, updatedMergedMap); } nodeOutgoingTraces.set(nodeId, myOutgoingTraces); // Update Node Data with INCOMING traces (for sidebar selection) // We store uniqueIncoming in node.data.traces // Note: We need to update the node in the `nodes` array, but we are inside the loop. // We'll do a bulk set at the end. }); // 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 = 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 = uniqMerged((n.data.mergedTraces || []).filter( (m: MergedTrace) => !mergedToDelete.includes(m.id) )); // Apply updated messages and colors to merged traces if (updatedMerged && updatedMerged.size > 0) { filteredMergedTraces = filteredMergedTraces.map((m: MergedTrace) => { const updated = updatedMerged.get(m.id); if (updated) { return { ...m, messages: updated.messages, colors: updated.colors }; } return m; }); } // Clean up unused forked traces const filteredForkedTraces = cleanedForks !== undefined ? cleanedForks : (n.data.forkedTraces || []); // Update activeTraceIds: remove deleted merged traces and truly orphaned fork traces // Preserve all other valid trace selections const activeForkIds = filteredForkedTraces.map(f => f.id); const incomingTraceIds = traces.map(t => t.id); const outgoingTraceIds = outTraces.map(t => t.id); const originalActiveIds = n.data.activeTraceIds || []; const filteredActiveTraceIds = originalActiveIds.filter( (id: string) => { // Remove deleted merged traces if (mergedToDelete.includes(id)) return false; // For fork traces: only remove if truly orphaned // A fork is NOT orphaned if it's in forkedTraces, incoming traces, or outgoing traces if (id.startsWith('fork-')) { const isInForkedTraces = activeForkIds.includes(id); const isIncomingTrace = incomingTraceIds.includes(id); const isOutgoingTrace = outgoingTraceIds.includes(id); if (!isInForkedTraces && !isIncomingTrace && !isOutgoingTrace) return false; } // Keep all other selections return true; } ); return { ...n, data: { ...n.data, traces, outgoingTraces: outTraces, forkedTraces: filteredForkedTraces, mergedTraces: filteredMergedTraces, activeTraceIds: filteredActiveTraceIds } }; }) })); }, }; }); export default useFlowStore;