From a14a53edbacb24051a31e73e2d111307c2f0354e Mon Sep 17 00:00:00 2001 From: blackhao <13851610112@163.com> Date: Mon, 8 Dec 2025 19:54:58 -0600 Subject: before file functions --- frontend/src/components/Sidebar.tsx | 261 +++++++++++++++++++++++------------- 1 file changed, 170 insertions(+), 91 deletions(-) (limited to 'frontend/src/components/Sidebar.tsx') diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 28a40f6..5516629 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; +import { useReactFlow } from 'reactflow'; 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'; +import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation } from 'lucide-react'; interface SidebarProps { isOpen: boolean; @@ -16,6 +17,7 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { isTraceComplete, createQuickChatNode, theme, createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages } = useFlowStore(); + const { setCenter } = useReactFlow(); const isDark = theme === 'dark'; const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact'); const [streamBuffer, setStreamBuffer] = useState(''); @@ -34,6 +36,7 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { // Quick Chat states const [quickChatOpen, setQuickChatOpen] = useState(false); const [quickChatTrace, setQuickChatTrace] = useState(null); + const [quickChatLastNodeId, setQuickChatLastNodeId] = useState(null); // Track the last node in the chat chain const [quickChatMessages, setQuickChatMessages] = useState([]); const [quickChatInput, setQuickChatInput] = useState(''); const [quickChatModel, setQuickChatModel] = useState('gpt-5.1'); @@ -431,6 +434,7 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { }); setQuickChatMessages(initialMessages); setQuickChatNeedsDuplicate(false); + setQuickChatLastNodeId(selectedNode.id); } else { // Use existing trace context const hasDownstream = traceHasDownstream(trace); @@ -452,6 +456,10 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { messages: fullMessages }); setQuickChatMessages(fullMessages); + + // Set last node ID: if current node has response, start from here. + // Otherwise start from trace source (which is the last completed node) + setQuickChatLastNodeId(hasResponse ? selectedNode.id : trace.sourceNodeId); } setQuickChatOpen(true); @@ -536,58 +544,95 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { return true; }; + // Helper: Check if specific trace path upstream has complete nodes + const checkTracePathComplete = (nodeId: string, traceId: string, visited: Set = new Set()): boolean => { + if (visited.has(nodeId)) return true; + visited.add(nodeId); + + // Find the incoming edge that carries this trace + const incomingEdge = edges.find(e => + e.target === nodeId && + e.sourceHandle === `trace-${traceId}` + ); + + if (!incomingEdge) return true; // Reached head of trace segment + + const sourceNode = nodes.find(n => n.id === incomingEdge.source); + if (!sourceNode || sourceNode.data.disabled) return true; + + // Check if source node is complete + if (!sourceNode.data.userPrompt || !sourceNode.data.response) { + return false; // Found incomplete node + } + + // Continue upstream + return checkTracePathComplete(sourceNode.id, traceId, visited); + }; + + // Helper: Find the first empty node on a specific trace path + const findEmptyNodeOnTrace = (nodeId: string, traceId: string, visited: Set = new Set()): string | null => { + if (visited.has(nodeId)) return null; + visited.add(nodeId); + + const incomingEdge = edges.find(e => + e.target === nodeId && + e.sourceHandle === `trace-${traceId}` + ); + + if (!incomingEdge) return null; + + const sourceNode = nodes.find(n => n.id === incomingEdge.source); + if (!sourceNode || sourceNode.data.disabled) return null; + + // Recursively check upstream first (find the furthest empty node) + const upstreamEmpty = findEmptyNodeOnTrace(sourceNode.id, traceId, visited); + if (upstreamEmpty) return upstreamEmpty; + + // If no further upstream empty, check this node + if (!sourceNode.data.userPrompt || !sourceNode.data.response) { + return sourceNode.id; + } + + return null; + }; + // 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 || []; + // Check upstream nodes ONLY for active 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 if it's a merged trace + const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId); + + if (merged) { + // For merged trace, check all source traces + for (const sourceId of merged.sourceTraceIds) { + if (!checkTracePathComplete(selectedNode.id, sourceId)) { + return { complete: false, incompleteTraceId: 'upstream' }; + } + } + } else { + // For regular trace + if (!checkTracePathComplete(selectedNode.id, traceId)) { + return { complete: false, incompleteTraceId: 'upstream' }; + } } } - // 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 || []; + // Check incoming traces content (message integrity) + const incomingTraces = selectedNode.data.traces || []; 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 + const trace = incomingTraces.find((t: Trace) => t.id === traceId); + if (trace && !isTraceComplete(trace)) { + return { complete: false, incompleteTraceId: traceId }; } } - // Check merged traces (all source traces must be complete) + // Check merged traces content const mergedTraces = selectedNode.data.mergedTraces || []; for (const traceId of activeTraceIds) { const merged = mergedTraces.find((m: MergedTrace) => m.id === traceId); @@ -603,6 +648,35 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { return { complete: true }; }; + + // Navigate to an empty upstream node on the active traces + const navigateToEmptyNode = () => { + if (!selectedNode) return; + const activeTraceIds = selectedNode.data.activeTraceIds || []; + + for (const traceId of activeTraceIds) { + let emptyNodeId: string | null = null; + + const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId); + if (merged) { + for (const sourceId of merged.sourceTraceIds) { + emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, sourceId); + if (emptyNodeId) break; + } + } else { + emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, traceId); + } + + if (emptyNodeId) { + const emptyNode = nodes.find(n => n.id === emptyNodeId); + if (emptyNode) { + setCenter(emptyNode.position.x + 100, emptyNode.position.y + 50, { zoom: 1.2, duration: 500 }); + setSelectedNode(emptyNodeId); + return; // Found one, navigate and stop + } + } + } + }; const activeTracesCheck = selectedNode ? checkActiveTracesComplete() : { complete: true }; @@ -681,9 +755,9 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { } // 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; + // Use quickChatLastNodeId as the "current" node in the chat flow to ensure continuity + // If not set, fallback to quickChatTrace.sourceNodeId (initial state) + const fromNodeId = quickChatLastNodeId || quickChatTrace.sourceNodeId; const fromNode = nodes.find(n => n.id === fromNodeId); const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== ''; @@ -707,6 +781,9 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }] } : null); + // Update last node ID + setQuickChatLastNodeId(fromNodeId); + // Generate title generateTitle(fromNodeId, userInput, fullResponse); } else { @@ -820,30 +897,27 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { } } } else { - // This is a continuation - find the evolved trace that ends at fromNodeId + // This is a continuation - find the trace ID (should be preserved now) // 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); + return 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}`; + // ID is preserved, so handle ID is just trace-{id} + sourceHandle = `trace-${incoming.id}`; } else { - // Fallback 2: find any trace that ends with fromNodeId + // Fallback 2: find any trace that ends with fromNodeId (unlikely if ID preserved) const anyMatch = sourceNodeData?.data.outgoingTraces?.find( - t => t.id === `trace-${fromNodeId}` || t.id.endsWith(`_${fromNodeId}`) + t => t.id === `trace-${fromNodeId}` ); if (anyMatch) { sourceHandle = `trace-${anyMatch.id}`; @@ -852,31 +926,27 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { } } } else { - // For existing trace: find the evolved version of the original trace + // For existing trace: ID is preserved 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; + return traceBase === baseTraceId; // Exact match now }); 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); + return tId === baseTraceId; // Exact match now }); if (matchingIncoming) { - // Construct the evolved ID: {traceId}_{nodeId} - // Handle ID format in LLMNode is `trace-${evolvedTraceId}` - const evolvedId = `${matchingIncoming.id}_${fromNodeId}`; - sourceHandle = `trace-${evolvedId}`; + // ID is preserved + sourceHandle = `trace-${matchingIncoming.id}`; } } } @@ -907,46 +977,28 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { if (newNode && newNode.data.outgoingTraces) { // Find the trace that continues the current conversation - // It should end with the new node ID + // Now trace IDs don't evolve, so it should be simpler if (isMerged) { + // Merged traces might still need evolution or logic check + // For now assuming linear extension keeps same ID if we changed flowStore + // But merged trace logic in flowStore might still append ID? + // Let's check if evolved version exists const evolved = newNode.data.outgoingTraces.find(t => t.id === `${currentId}_${newNodeId}` ); if (evolved) nextTraceId = evolved.id; + else nextTraceId = currentId; // Try keeping same 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 + // For new trace, check if we have an outgoing trace with the start node ID const startNodeId = currentId.replace('new-trace-', ''); const match = newNode.data.outgoingTraces.find(t => - t.id.includes(startNodeId) && t.id.endsWith(`_${newNodeId}`) + t.id.includes(startNodeId) ); 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}`; - } - } + // Regular trace: ID should be preserved + nextTraceId = currentId; } } @@ -957,6 +1009,9 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }] } : null); + // Update last node ID to the new node + setQuickChatLastNodeId(newNodeId); + // Generate title generateTitle(newNodeId, userInput, fullResponse); }, 100); @@ -1237,6 +1292,20 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => { {selectedNode.data.mergedTraces.map((merged: MergedTrace) => { const isActive = selectedNode.data.activeTraceIds?.includes(merged.id); + // Check if merged trace is complete + const isComplete = merged.sourceTraceIds.every(sourceId => { + // Check trace path completeness (upstream empty nodes) + const pathComplete = checkTracePathComplete(selectedNode.id, sourceId); + if (!pathComplete) return false; + + // Check message integrity + const incomingTraces = selectedNode.data.traces || []; + const sourceTrace = incomingTraces.find(t => t.id === sourceId); + if (sourceTrace && !isTraceComplete(sourceTrace)) return false; + + return true; + }); + return (
= ({ isOpen, onToggle, onInteract }) => {
-
- Merged #{merged.id.slice(-6)} +
+ Merged #{merged.id.slice(-6)} + {!isComplete && ( + (incomplete) + )}
{merged.strategy} • {merged.messages.length} msgs @@ -1338,8 +1410,15 @@ const Sidebar: React.FC = ({ isOpen, onToggle, onInteract }) => {
- - Upstream node is empty. Complete the context chain before running. + + Upstream node is empty. Complete the context chain before running. +
)} -- cgit v1.2.3