import React, { useState, useEffect, useRef } from 'react'; import useFlowStore from '../store/flowStore'; import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore'; import ReactMarkdown from 'react-markdown'; import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2 } from 'lucide-react'; interface SidebarProps { isOpen: boolean; onToggle: () => void; onInteract?: () => void; } const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { const { nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode, isTraceComplete, createQuickChatNode, theme, createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages } = useFlowStore(); const isDark = theme === 'dark'; const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact'); const [streamBuffer, setStreamBuffer] = useState(''); const [streamingNodeId, setStreamingNodeId] = useState(null); // Track which node is streaming // Response Modal & Edit states const [isModalOpen, setIsModalOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editedResponse, setEditedResponse] = useState(''); // Summary states const [showSummaryModal, setShowSummaryModal] = useState(false); const [summaryModel, setSummaryModel] = useState('gpt-5-nano'); const [isSummarizing, setIsSummarizing] = useState(false); // Quick Chat states const [quickChatOpen, setQuickChatOpen] = useState(false); const [quickChatTrace, setQuickChatTrace] = useState(null); const [quickChatMessages, setQuickChatMessages] = useState([]); const [quickChatInput, setQuickChatInput] = useState(''); const [quickChatModel, setQuickChatModel] = useState('gpt-5.1'); const [quickChatLoading, setQuickChatLoading] = useState(false); const [quickChatTemp, setQuickChatTemp] = useState(0.7); const [quickChatEffort, setQuickChatEffort] = useState<'low' | 'medium' | 'high'>('medium'); const [quickChatNeedsDuplicate, setQuickChatNeedsDuplicate] = useState(false); const [quickChatWebSearch, setQuickChatWebSearch] = useState(true); const quickChatEndRef = useRef(null); const quickChatInputRef = useRef(null); // Merge Trace states const [showMergeModal, setShowMergeModal] = useState(false); const [mergeSelectedIds, setMergeSelectedIds] = useState([]); const [mergeStrategy, setMergeStrategy] = useState('query_time'); const [mergeDraggedId, setMergeDraggedId] = useState(null); const [mergeOrder, setMergeOrder] = useState([]); const [showMergePreview, setShowMergePreview] = useState(false); const [isSummarizingMerge, setIsSummarizingMerge] = useState(false); const selectedNode = nodes.find((n) => n.id === selectedNodeId); // Reset stream buffer and modal states when node changes useEffect(() => { setStreamBuffer(''); setIsModalOpen(false); setIsEditing(false); setShowMergeModal(false); setMergeSelectedIds([]); setShowMergePreview(false); }, [selectedNodeId]); // Default select first trace when node changes and no trace is selected useEffect(() => { if (selectedNode && selectedNode.data.traces && selectedNode.data.traces.length > 0 && (!selectedNode.data.activeTraceIds || selectedNode.data.activeTraceIds.length === 0)) { updateNodeData(selectedNode.id, { activeTraceIds: [selectedNode.data.traces[0].id] }); } }, [selectedNodeId, selectedNode?.data.traces?.length]); // Sync editedResponse when entering edit mode useEffect(() => { if (isEditing && selectedNode) { setEditedResponse(selectedNode.data.response || ''); } }, [isEditing, selectedNode?.data.response]); // Scroll to bottom when quick chat messages change useEffect(() => { if (quickChatEndRef.current) { quickChatEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [quickChatMessages]); if (!isOpen) { return (
{selectedNode && (
{selectedNode.data.label}
)}
); } if (!selectedNode) { return (
Details

Select a node to edit

); } const handleRun = async () => { if (!selectedNode) return; // Check if upstream is complete before running const tracesCheck = checkActiveTracesComplete(); if (!tracesCheck.complete) { console.warn('Cannot run: upstream context is incomplete'); return; } // Capture the node ID at the start of the request const runningNodeId = selectedNode.id; const runningPrompt = selectedNode.data.userPrompt; // Record query sent timestamp const querySentAt = Date.now(); updateNodeData(runningNodeId, { status: 'loading', response: '', querySentAt }); setStreamBuffer(''); setStreamingNodeId(runningNodeId); // Use getActiveContext which respects the user's selected traces const context = getActiveContext(runningNodeId); try { const response = await fetch('http://localhost:8000/api/run_node_stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: runningNodeId, incoming_contexts: [{ messages: context }], user_prompt: runningPrompt, merge_strategy: selectedNode.data.mergeStrategy || 'smart', config: { provider: selectedNode.data.model.includes('gpt') || selectedNode.data.model === 'o3' ? 'openai' : 'google', model_name: selectedNode.data.model, temperature: selectedNode.data.temperature, system_prompt: selectedNode.data.systemPrompt, api_key: selectedNode.data.apiKey, enable_google_search: selectedNode.data.enableGoogleSearch !== false, reasoning_effort: selectedNode.data.reasoningEffort || 'medium', } }) }); if (!response.body) return; const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullResponse = ''; while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value); fullResponse += chunk; // Only update stream buffer, the display logic will check streamingNodeId setStreamBuffer(prev => prev + chunk); } // Update final state using captured nodeId const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', content: runningPrompt }; const newAssistantMsg = { id: `msg_${Date.now()}_a`, role: 'assistant', content: fullResponse }; const responseReceivedAt = Date.now(); updateNodeData(runningNodeId, { status: 'success', response: fullResponse, responseReceivedAt, messages: [...context, newUserMsg, newAssistantMsg] as any }); // Auto-generate title generateTitle(runningNodeId, runningPrompt, fullResponse); } catch (error) { console.error(error); updateNodeData(runningNodeId, { status: 'error' }); } finally { setStreamingNodeId(prev => prev === runningNodeId ? null : prev); } }; const handleChange = (field: keyof NodeData, value: any) => { updateNodeData(selectedNode.id, { [field]: value }); }; const handleSaveEdit = () => { if (!selectedNode) return; updateNodeData(selectedNode.id, { response: editedResponse }); setIsEditing(false); }; const handleCancelEdit = () => { setIsEditing(false); setEditedResponse(selectedNode?.data.response || ''); }; // Summarize response const handleSummarize = async () => { if (!selectedNode?.data.response) return; setIsSummarizing(true); setShowSummaryModal(false); try { const res = await fetch('http://localhost:8000/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: selectedNode.data.response, model: summaryModel }) }); if (res.ok) { const data = await res.json(); if (data.summary) { // Replace response with summary updateNodeData(selectedNode.id, { response: data.summary }); } } } catch (error) { console.error('Summarization failed:', error); } finally { setIsSummarizing(false); } }; // Auto-generate title using gpt-5-nano const generateTitle = async (nodeId: string, userPrompt: string, response: string) => { try { const res = await fetch('http://localhost:8000/api/generate_title', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_prompt: userPrompt, response }) }); if (res.ok) { const data = await res.json(); if (data.title) { updateNodeData(nodeId, { label: data.title }); } } } catch (error) { console.error('Failed to generate title:', error); // Silently fail - keep the original title } }; // Open merge modal const openMergeModal = () => { if (!selectedNode?.data.traces) return; const traceIds = selectedNode.data.traces.map((t: Trace) => t.id); setMergeOrder(traceIds); setMergeSelectedIds([]); setShowMergePreview(false); setShowMergeModal(true); }; // Drag-and-drop handlers for merge modal const handleMergeDragStart = (e: React.DragEvent, traceId: string) => { setMergeDraggedId(traceId); e.dataTransfer.effectAllowed = 'move'; }; const handleMergeDragOver = (e: React.DragEvent, overTraceId: string) => { e.preventDefault(); if (!mergeDraggedId || mergeDraggedId === overTraceId) return; const newOrder = [...mergeOrder]; const draggedIndex = newOrder.indexOf(mergeDraggedId); const overIndex = newOrder.indexOf(overTraceId); if (draggedIndex !== -1 && overIndex !== -1) { newOrder.splice(draggedIndex, 1); newOrder.splice(overIndex, 0, mergeDraggedId); setMergeOrder(newOrder); } }; const handleMergeDragEnd = () => { setMergeDraggedId(null); }; // Toggle trace selection in merge modal const toggleMergeSelection = (traceId: string) => { setMergeSelectedIds(prev => { if (prev.includes(traceId)) { return prev.filter(id => id !== traceId); } else { return [...prev, traceId]; } }); }; // Create merged trace const handleCreateMergedTrace = async () => { if (!selectedNode || mergeSelectedIds.length < 2) return; // Get the ordered trace IDs based on mergeOrder const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id)); if (mergeStrategy === 'summary') { setIsSummarizingMerge(true); try { const messages = computeMergedMessages(selectedNode.id, orderedSelectedIds, 'trace_order'); const content = messages.map(m => `${m.role}: ${m.content}`).join('\n\n'); const res = await fetch('http://localhost:8000/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, model_name: 'gpt-5-nano', api_key: selectedNode.data.apiKey }) }); if (res.ok) { const data = await res.json(); const mergedId = createMergedTrace(selectedNode.id, orderedSelectedIds, 'summary'); if (mergedId && data.summary) { updateMergedTrace(selectedNode.id, mergedId, { summarizedContent: data.summary }); } } } catch (error) { console.error('Failed to summarize for merge:', error); } finally { setIsSummarizingMerge(false); } } else { createMergedTrace(selectedNode.id, orderedSelectedIds, mergeStrategy); } // Close modal and reset setShowMergeModal(false); setMergeSelectedIds([]); setShowMergePreview(false); }; // Get preview of merged messages const getMergePreview = () => { if (!selectedNode || mergeSelectedIds.length < 2) return []; const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id)); return computeMergedMessages(selectedNode.id, orderedSelectedIds, mergeStrategy); }; // Check if a trace has downstream nodes from the current selected node const traceHasDownstream = (trace: Trace): boolean => { if (!selectedNode) return false; // Find edges going out from selectedNode that are part of this trace const outgoingEdge = edges.find(e => e.source === selectedNode.id && e.sourceHandle?.startsWith('trace-') ); return !!outgoingEdge; }; // Quick Chat functions const openQuickChat = (trace: Trace | null, isNewTrace: boolean = false) => { if (!selectedNode) return; onInteract?.(); // Close context menu when opening quick chat // Check if current node has a "sent" query (has response) or just unsent draft const hasResponse = !!selectedNode.data.response; const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse; if (isNewTrace || !trace) { // Start a new trace from current node const initialMessages: Message[] = []; // Only include user prompt as message if it was actually sent (has response) if (selectedNode.data.userPrompt && hasResponse) { initialMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt }); } if (selectedNode.data.response) { initialMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response }); } setQuickChatTrace({ id: `new-trace-${selectedNode.id}`, sourceNodeId: selectedNode.id, color: '#888', messages: initialMessages }); setQuickChatMessages(initialMessages); setQuickChatNeedsDuplicate(false); } else { // Use existing trace context const hasDownstream = traceHasDownstream(trace); setQuickChatNeedsDuplicate(hasDownstream); // Build full message history const fullMessages: Message[] = [...trace.messages]; // Only include current node's content if it was sent if (selectedNode.data.userPrompt && hasResponse) { fullMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt }); } if (selectedNode.data.response) { fullMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response }); } setQuickChatTrace({ ...trace, sourceNodeId: selectedNode.id, messages: fullMessages }); setQuickChatMessages(fullMessages); } setQuickChatOpen(true); // If there's an unsent draft, put it in the input box setQuickChatInput(hasDraftPrompt ? selectedNode.data.userPrompt : ''); }; const closeQuickChat = () => { setQuickChatOpen(false); setQuickChatTrace(null); setQuickChatMessages([]); }; // Open Quick Chat for a merged trace const openMergedQuickChat = (merged: MergedTrace) => { if (!selectedNode) return; onInteract?.(); // Check if current node has a "sent" query (has response) or just unsent draft const hasResponse = !!selectedNode.data.response; const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse; // Build messages from merged trace const fullMessages: Message[] = [...merged.messages]; // Only include current node's content if it was sent if (selectedNode.data.userPrompt && hasResponse) { fullMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt }); } if (selectedNode.data.response) { fullMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response }); } // Create a pseudo-trace for the merged context setQuickChatTrace({ id: merged.id, sourceNodeId: selectedNode.id, color: merged.colors[0] || '#888', messages: fullMessages }); setQuickChatMessages(fullMessages); setQuickChatNeedsDuplicate(false); // Merged traces don't duplicate setQuickChatOpen(true); // If there's an unsent draft, put it in the input box setQuickChatInput(hasDraftPrompt ? selectedNode.data.userPrompt : ''); }; // Check if a trace is complete (all upstream nodes have Q&A) const canQuickChat = (trace: Trace): boolean => { return isTraceComplete(trace); }; // Helper: Check if all upstream nodes have complete Q&A by traversing edges const checkUpstreamNodesComplete = (nodeId: string, visited: Set = new Set()): boolean => { if (visited.has(nodeId)) return true; // Avoid cycles visited.add(nodeId); const node = nodes.find(n => n.id === nodeId); if (!node) return true; // Find all incoming edges to this node const incomingEdges = edges.filter(e => e.target === nodeId); for (const edge of incomingEdges) { const sourceNode = nodes.find(n => n.id === edge.source); if (!sourceNode) continue; // Check if source node is disabled - skip disabled nodes if (sourceNode.data.disabled) continue; // Check if source node has complete Q&A if (!sourceNode.data.userPrompt || !sourceNode.data.response) { return false; // Found an incomplete upstream node } // Recursively check further upstream if (!checkUpstreamNodesComplete(edge.source, visited)) { return false; } } return true; }; // Check if all active traces are complete (for main Run Node button) const checkActiveTracesComplete = (): { complete: boolean; incompleteTraceId?: string } => { if (!selectedNode) return { complete: true }; // FIRST: Always check if all upstream nodes (via edges) have complete Q&A // This has highest priority - even if no trace is selected if (!checkUpstreamNodesComplete(selectedNode.id)) { return { complete: false, incompleteTraceId: 'upstream' }; } const activeTraceIds = selectedNode.data.activeTraceIds || []; if (activeTraceIds.length === 0) return { complete: true }; // Check incoming traces - these represent upstream context const incomingTraces = selectedNode.data.traces || []; for (const traceId of activeTraceIds) { const trace = incomingTraces.find((t: Trace) => t.id === traceId); if (trace && !isTraceComplete(trace)) { return { complete: false, incompleteTraceId: traceId }; } } // Check outgoing traces (for originated traces) // But for traces that THIS node originated (self trace, forked traces), // we only need to check if there are incomplete UPSTREAM messages // (not the current node's own messages) const outgoingTraces = selectedNode.data.outgoingTraces || []; for (const traceId of activeTraceIds) { const trace = outgoingTraces.find((t: Trace) => t.id === traceId); if (trace) { // Filter out current node's own messages const upstreamMessages = trace.messages.filter(m => !m.id?.startsWith(`${selectedNode.id}-`)); // Only check completeness if there are upstream messages // Empty upstream means this is a head node - that's fine if (upstreamMessages.length > 0) { let userCount = 0; let assistantCount = 0; for (const msg of upstreamMessages) { if (msg.role === 'user') userCount++; if (msg.role === 'assistant') assistantCount++; } // Incomplete if unbalanced upstream messages if (userCount !== assistantCount) { return { complete: false, incompleteTraceId: traceId }; } } // If no upstream messages, this is a head node - always complete } } // Check merged traces (all source traces must be complete) const mergedTraces = selectedNode.data.mergedTraces || []; for (const traceId of activeTraceIds) { const merged = mergedTraces.find((m: MergedTrace) => m.id === traceId); if (merged) { for (const sourceId of merged.sourceTraceIds) { const sourceTrace = incomingTraces.find((t: Trace) => t.id === sourceId); if (sourceTrace && !isTraceComplete(sourceTrace)) { return { complete: false, incompleteTraceId: sourceId }; } } } } return { complete: true }; }; const activeTracesCheck = selectedNode ? checkActiveTracesComplete() : { complete: true }; const handleQuickChatSend = async () => { if (!quickChatInput.trim() || !quickChatTrace || quickChatLoading || !selectedNode) return; const userInput = quickChatInput; const userMessage: Message = { id: `qc_${Date.now()}_u`, role: 'user', content: userInput }; // Add user message to display const messagesBeforeSend = [...quickChatMessages]; setQuickChatMessages(prev => [...prev, userMessage]); setQuickChatInput(''); setQuickChatLoading(true); // Store model at send time to avoid issues with model switching during streaming const modelAtSend = quickChatModel; const tempAtSend = quickChatTemp; const effortAtSend = quickChatEffort; const webSearchAtSend = quickChatWebSearch; try { // Determine provider const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3'; const reasoningModels = ['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3']; const isReasoning = reasoningModels.includes(modelAtSend); // Call LLM API with current messages as context const response = await fetch('http://localhost:8000/api/run_node_stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: 'quick_chat_temp', incoming_contexts: [{ messages: messagesBeforeSend }], user_prompt: userInput, merge_strategy: 'smart', config: { provider: isOpenAI ? 'openai' : 'google', model_name: modelAtSend, temperature: isReasoning ? 1 : tempAtSend, enable_google_search: webSearchAtSend, reasoning_effort: effortAtSend, } }) }); if (!response.body) throw new Error('No response body'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullResponse = ''; // Stream response while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value); fullResponse += chunk; // Update display in real-time setQuickChatMessages(prev => { const newMsgs = [...prev]; const lastMsg = newMsgs[newMsgs.length - 1]; if (lastMsg?.role === 'assistant') { // Update existing assistant message return [...newMsgs.slice(0, -1), { ...lastMsg, content: fullResponse }]; } else { // Add new assistant message return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]; } }); } // Determine whether to overwrite current node or create new one // Use quickChatTrace.sourceNodeId as the "current" node in the chat flow // This allows continuous chaining: A -> B -> C const fromNodeId = quickChatTrace.sourceNodeId; const fromNode = nodes.find(n => n.id === fromNodeId); const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== ''; if (!fromNodeHasResponse && fromNode) { // Overwrite the source node (it's empty) updateNodeData(fromNodeId, { userPrompt: userInput, response: fullResponse, model: modelAtSend, temperature: isReasoning ? 1 : tempAtSend, reasoningEffort: effortAtSend, enableGoogleSearch: webSearchAtSend, status: 'success', querySentAt: Date.now(), responseReceivedAt: Date.now() }); // Update trace to reflect current node now has content setQuickChatTrace(prev => prev ? { ...prev, messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }] } : null); // Generate title generateTitle(fromNodeId, userInput, fullResponse); } else { // Create new node (source node has response, continue the chain) const newNodeId = `node_${Date.now()}`; const sourceNode = fromNode || selectedNode; const newPos = { x: sourceNode.position.x + 300, y: sourceNode.position.y }; const newNode = { id: newNodeId, type: 'llmNode', position: newPos, data: { label: 'Quick Chat', model: modelAtSend, temperature: isReasoning ? 1 : tempAtSend, systemPrompt: '', userPrompt: userInput, mergeStrategy: 'smart' as const, reasoningEffort: effortAtSend, enableGoogleSearch: webSearchAtSend, traces: [], outgoingTraces: [], forkedTraces: [], mergedTraces: [], activeTraceIds: [], response: fullResponse, status: 'success' as const, inputs: 1, querySentAt: Date.now(), responseReceivedAt: Date.now() } }; addNode(newNode); // Connect to the source node setTimeout(() => { const store = useFlowStore.getState(); const currentEdges = store.edges; const sourceNodeData = store.nodes.find(n => n.id === fromNodeId); // Find the right trace handle to use let sourceHandle = 'new-trace'; // Get the base trace ID (e.g., 'trace-A' from 'trace-A_B_C' or 'new-trace-A' or 'merged-xxx') const currentTraceId = quickChatTrace?.id || ''; const isNewTrace = currentTraceId.startsWith('new-trace-'); const isMergedTrace = currentTraceId.startsWith('merged-'); if (isMergedTrace) { // For merged trace: find the merged trace handle on the source node // The trace ID may have evolved (e.g., 'merged-xxx' -> 'merged-xxx_nodeA' -> 'merged-xxx_nodeA_nodeB') // We need to find the version that ends with the current source node ID // First try: exact match with evolved ID (merged-xxx_sourceNodeId) const evolvedMergedId = `${currentTraceId}_${fromNodeId}`; let mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find( t => t.id === evolvedMergedId ); // Second try: find trace that starts with merged ID and ends with this node if (!mergedOutgoing) { mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find( t => t.id.startsWith(currentTraceId) && t.id.endsWith(`_${fromNodeId}`) ); } // Third try: find any trace that contains the merged ID if (!mergedOutgoing) { mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find( t => t.id.startsWith(currentTraceId) || t.id === currentTraceId ); } // Fourth try: find any merged trace if (!mergedOutgoing) { mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find( t => t.id.startsWith('merged-') ); } if (mergedOutgoing) { sourceHandle = `trace-${mergedOutgoing.id}`; } else { // Last resort: use the merged trace ID directly sourceHandle = `trace-${currentTraceId}`; } } else if (isNewTrace) { // For "Start New Trace": create a fresh independent trace from the original node // First, check if this is the original starting node or a continuation node const originalStartNodeId = currentTraceId.replace('new-trace-', ''); const isOriginalNode = fromNodeId === originalStartNodeId; if (isOriginalNode) { // This is the first round - starting from original node const hasOutgoingEdges = currentEdges.some(e => e.source === fromNodeId); if (hasOutgoingEdges) { // Original node already has downstream - create a new fork sourceHandle = 'new-trace'; } else { // No downstream yet - use self trace const selfTrace = sourceNodeData?.data.outgoingTraces?.find( t => t.id === `trace-${fromNodeId}` ); if (selfTrace) { sourceHandle = `trace-${selfTrace.id}`; } } } else { // This is a continuation - find the evolved trace that ends at fromNodeId // Look for a trace that was created from the original node's self trace const matchingTrace = sourceNodeData?.data.outgoingTraces?.find(t => { // The trace should end with fromNodeId and contain the original node return t.id.endsWith(`_${fromNodeId}`) && t.id.includes(originalStartNodeId); }); if (matchingTrace) { sourceHandle = `trace-${matchingTrace.id}`; } else { // Fallback 1: Check INCOMING traces (Connect to Continue Handle) // This is crucial because pass-through traces are not in outgoingTraces until connected const incoming = sourceNodeData?.data.traces?.find(t => t.id.includes(originalStartNodeId) ); if (incoming) { // Construct evolved ID for continue handle const evolvedId = `${incoming.id}_${fromNodeId}`; sourceHandle = `trace-${evolvedId}`; } else { // Fallback 2: find any trace that ends with fromNodeId const anyMatch = sourceNodeData?.data.outgoingTraces?.find( t => t.id === `trace-${fromNodeId}` || t.id.endsWith(`_${fromNodeId}`) ); if (anyMatch) { sourceHandle = `trace-${anyMatch.id}`; } } } } } else { // For existing trace: find the evolved version of the original trace const baseTraceId = currentTraceId.replace(/^trace-/, ''); // 1. Try OUTGOING traces first (if already connected downstream) const matchingOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => { const traceBase = t.id.replace(/^trace-/, ''); return traceBase.startsWith(baseTraceId) || traceBase === baseTraceId; }); if (matchingOutgoing) { sourceHandle = `trace-${matchingOutgoing.id}`; } else { // 2. Try INCOMING traces (Connect to Continue Handle) // If we are at Node B, and currentTraceId is "trace-A", // we look for incoming "trace-A" and use its continue handle "trace-trace-A_B" const matchingIncoming = sourceNodeData?.data.traces?.find(t => { const tId = t.id.replace(/^trace-/, ''); return tId === baseTraceId || baseTraceId.startsWith(tId); }); if (matchingIncoming) { // Construct the evolved ID: {traceId}_{nodeId} // Handle ID format in LLMNode is `trace-${evolvedTraceId}` const evolvedId = `${matchingIncoming.id}_${fromNodeId}`; sourceHandle = `trace-${evolvedId}`; } } } // If this is the first message and we need to duplicate (has downstream), // onConnect will automatically handle the trace duplication // because the sourceHandle already has an outgoing edge store.onConnect({ source: fromNodeId, sourceHandle, target: newNodeId, targetHandle: 'input-0' }); // After first duplication, subsequent messages continue on the new trace // Reset the duplicate flag since we're now on the new branch setQuickChatNeedsDuplicate(false); // Update trace for continued chat - use newNodeId as the new source // Find the actual trace ID on the new node to ensure continuity const newNode = store.nodes.find(n => n.id === newNodeId); const currentId = quickChatTrace?.id || ''; const isMerged = currentId.startsWith('merged-'); const isCurrentNewTrace = currentId.startsWith('new-trace-'); let nextTraceId = currentId; if (newNode && newNode.data.outgoingTraces) { // Find the trace that continues the current conversation // It should end with the new node ID if (isMerged) { const evolved = newNode.data.outgoingTraces.find(t => t.id === `${currentId}_${newNodeId}` ); if (evolved) nextTraceId = evolved.id; } else if (isCurrentNewTrace) { // For new trace, we look for the trace that originated from the start node // and now passes through the new node const startNodeId = currentId.replace('new-trace-', ''); const match = newNode.data.outgoingTraces.find(t => t.id.includes(startNodeId) && t.id.endsWith(`_${newNodeId}`) ); if (match) nextTraceId = match.id; // If first step (A->B), might be a direct fork ID else { const directFork = newNode.data.outgoingTraces.find(t => t.id.includes(startNodeId) && t.sourceNodeId === startNodeId ); if (directFork) nextTraceId = directFork.id; } } else { // Regular trace: look for evolved version const baseId = currentId.replace(/^trace-/, ''); // 1. Try outgoing traces const match = newNode.data.outgoingTraces.find(t => t.id.includes(baseId) && t.id.endsWith(`_${newNodeId}`) ); if (match) { nextTraceId = match.id; } else { // 2. If not in outgoing (no downstream yet), construct evolved ID manually // Check if it's an incoming trace that evolved const incoming = newNode.data.traces?.find(t => t.id.includes(baseId)); if (incoming) { nextTraceId = `${incoming.id}_${newNodeId}`; } } } } setQuickChatTrace(prev => prev ? { ...prev, id: nextTraceId, sourceNodeId: newNodeId, messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }] } : null); // Generate title generateTitle(newNodeId, userInput, fullResponse); }, 100); } } catch (error) { console.error('Quick chat error:', error); setQuickChatMessages(prev => [...prev, { id: `qc_err_${Date.now()}`, role: 'assistant', content: `Error: ${error}` }]); } finally { setQuickChatLoading(false); // Refocus the input after sending setTimeout(() => { quickChatInputRef.current?.focus(); }, 50); } }; return (
{/* Header */}
handleChange('label', e.target.value)} className={`font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full ${ isDark ? 'text-gray-200' : 'text-gray-900' }`} />
{selectedNode.data.status}
ID: {selectedNode.id}
{/* Tabs */}
{/* Content */}
{activeTab === 'interact' && (
{/* Trace Selector - Single Select */}
{/* Create Merged Trace Button - only show if 2+ traces */} {selectedNode.data.traces && selectedNode.data.traces.length >= 2 && ( )}
{/* New Trace option */}
Start New Trace
{/* All Available Traces - Incoming + Outgoing that this node originated */} {(() => { // 1. Incoming traces (context from upstream) const incomingTraces = selectedNode.data.traces || []; // 2. Outgoing traces that this node ORIGINATED (not pass-through, not merged) // This includes self-started traces, forked traces, and prepend traces const outgoingTraces = (selectedNode.data.outgoingTraces || []) as Trace[]; const originatedTraces = outgoingTraces.filter(t => { // Exclude merged traces - they have their own display section if (t.id.startsWith('merged-')) return false; // Include if this node is the source (originated here) // OR if the trace ID matches a forked/prepend trace pattern from this node const isOriginated = t.sourceNodeId === selectedNode.id; const isForkedHere = t.id.includes(`fork-${selectedNode.id}`); const isSelfTrace = t.id === `trace-${selectedNode.id}`; return isOriginated || isForkedHere || isSelfTrace; }); // Combine and deduplicate by ID // Priority: incoming traces (have full context) > originated outgoing traces const allTracesMap = new Map(); // Add originated outgoing traces first originatedTraces.forEach(t => allTracesMap.set(t.id, t)); // Then incoming traces (will overwrite if same ID, as they have fuller context) incomingTraces.forEach(t => allTracesMap.set(t.id, t)); const allTraces = Array.from(allTracesMap.values()); if (allTraces.length === 0) return null; return (
{allTraces.map((trace: Trace) => { const isActive = selectedNode.data.activeTraceIds?.includes(trace.id); const isComplete = canQuickChat(trace); return (
handleChange('activeTraceIds', [trace.id])} className={`flex items-start gap-2 text-sm p-1.5 rounded group cursor-pointer transition-all ${ isActive ? isDark ? 'bg-blue-900/50 border border-blue-700' : 'bg-blue-50 border border-blue-200' : isDark ? 'hover:bg-gray-800' : 'hover:bg-white' }`} >
#{trace.id.slice(-4)} {!isComplete && ( (incomplete) )}
{trace.messages.length} msgs
{/* Quick Chat Button */} {(() => { const hasDownstream = edges.some(e => e.source === selectedNode.id && e.sourceHandle?.startsWith('trace-') ); const buttonLabel = hasDownstream ? "Duplicate & Quick Chat" : "Quick Chat"; return ( ); })()}
); })}
); })()} {/* Merged Traces - also single selectable */} {selectedNode.data.mergedTraces && selectedNode.data.mergedTraces.length > 0 && (
{selectedNode.data.mergedTraces.map((merged: MergedTrace) => { const isActive = selectedNode.data.activeTraceIds?.includes(merged.id); return (
handleChange('activeTraceIds', [merged.id])} className={`flex items-center gap-2 p-1.5 rounded text-xs cursor-pointer transition-all ${ isActive ? isDark ? 'bg-purple-900/50 border border-purple-600' : 'bg-purple-50 border border-purple-300' : isDark ? 'bg-gray-800 hover:bg-gray-700' : 'bg-white border border-gray-200 hover:bg-gray-50' }`} > {/* Alternating color indicator */}
{merged.colors.slice(0, 3).map((color, idx) => (
))} {merged.colors.length > 3 && (
+{merged.colors.length - 3}
)}
Merged #{merged.id.slice(-6)}
{merged.strategy} • {merged.messages.length} msgs
{/* Quick Chat for Merged Trace */}
); })}
)}