diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-14 05:07:13 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-14 05:07:13 +0000 |
| commit | 147451f94030cd47a24821015f12cde967bb2305 (patch) | |
| tree | b9becba4aefbc927ec1df1c58f9bd408f5b2e9f0 | |
| parent | db1249889090ba6283dd92783776bf500f36d9e4 (diff) | |
Create quick chat nodes immediately on send, fix Enter key routing
- Move node creation decision before API call so new nodes appear
instantly with loading state instead of waiting for full response
- Use useFlowStore.getState().nodes for fresh state in the decision
- Fix Enter key in prompt textarea to call handleRunCouncil/handleRunDebate
for council/debate nodes instead of always calling handleRun
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 457 |
1 files changed, 183 insertions, 274 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 08f0988..bf0363d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1334,7 +1334,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const userInput = quickChatInput; const attachedFilesCopy = [...quickChatAttachedFiles]; const msgId = `qc_${Date.now()}_u`; - + const userMessage: Message = { id: msgId, role: 'user', @@ -1365,11 +1365,152 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { const councilModelsAtSend = isCouncilSend ? [...quickChatCouncilModels] : []; const chairmanAtSend = isCouncilSend ? (quickChatChairmanModel || quickChatCouncilModels[0]) : null; + // ====== Determine target node BEFORE API call ====== + // Create new node immediately so it appears on the blueprint with loading state + const fromNodeId = quickChatLastNodeId || quickChatTrace.sourceNodeId; + const freshNodes = useFlowStore.getState().nodes; + const fromNode = freshNodes.find(n => n.id === fromNodeId); + const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== ''; + + let targetNodeId: string; // The node that will receive the response + let createdNewNode = false; + + if (!fromNodeHasResponse && fromNode) { + // Overwrite the source node (it's empty) — set loading immediately + targetNodeId = fromNodeId; + const nodeUpdate: any = { + userPrompt: userInput, + model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, + temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), + reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, + enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, + attachedFileIds: attachedFilesCopy, + status: 'loading', + response: '', + querySentAt: Date.now(), + }; + if (isCouncilSend) { + nodeUpdate.councilMode = true; + nodeUpdate.councilModels = councilModelsAtSend; + nodeUpdate.chairmanModel = chairmanAtSend; + } + updateNodeData(targetNodeId, nodeUpdate); + setQuickChatLastNodeId(fromNodeId); + } else { + // Create new node immediately with loading state + createdNewNode = true; + targetNodeId = `node_${Date.now()}`; + const sourceNode = fromNode || selectedNode; + const newPos = { + x: sourceNode.position.x + 300, + y: sourceNode.position.y + }; + + const newNodeData: any = { + label: isCouncilSend ? 'Council Chat' : 'Quick Chat', + model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, + temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), + systemPrompt: '', + userPrompt: userInput, + mergeStrategy: 'smart' as const, + reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, + enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, + traces: [], + outgoingTraces: [], + forkedTraces: [], + mergedTraces: [], + activeTraceIds: [], + attachedFileIds: attachedFilesCopy, + response: '', + status: 'loading' as const, + inputs: 1, + querySentAt: Date.now(), + }; + if (isCouncilSend) { + newNodeData.councilMode = true; + newNodeData.councilModels = councilModelsAtSend; + newNodeData.chairmanModel = chairmanAtSend; + } + + addNode({ + id: targetNodeId, + type: 'llmNode', + position: newPos, + data: newNodeData, + }); + + // Connect edge immediately + setTimeout(() => { + const store = useFlowStore.getState(); + const currentEdges = store.edges; + const sourceNodeData = store.nodes.find(n => n.id === fromNodeId); + + let sourceHandle = 'new-trace'; + const currentTraceId = quickChatTrace?.id || ''; + const isNewTrace = currentTraceId.startsWith('new-trace-'); + const isMergedTrace = currentTraceId.startsWith('merged-'); + + if (isMergedTrace) { + const evolvedMergedId = `${currentTraceId}_${fromNodeId}`; + let mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id === evolvedMergedId); + if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith(currentTraceId) && t.id.endsWith(`_${fromNodeId}`)); + if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith(currentTraceId) || t.id === currentTraceId); + if (!mergedOutgoing) mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.startsWith('merged-')); + sourceHandle = mergedOutgoing ? `trace-${mergedOutgoing.id}` : `trace-${currentTraceId}`; + } else if (isNewTrace) { + const originalStartNodeId = currentTraceId.replace('new-trace-', ''); + const isOriginalNode = fromNodeId === originalStartNodeId; + if (isOriginalNode) { + const hasOutgoingEdges = currentEdges.some(e => e.source === fromNodeId); + if (hasOutgoingEdges) { + sourceHandle = 'new-trace'; + } else { + const selfTrace = sourceNodeData?.data.outgoingTraces?.find(t => t.id === `trace-${fromNodeId}`); + if (selfTrace) sourceHandle = `trace-${selfTrace.id}`; + } + } else { + const matchingTrace = sourceNodeData?.data.outgoingTraces?.find(t => t.id.includes(originalStartNodeId)); + if (matchingTrace) { + sourceHandle = `trace-${matchingTrace.id}`; + } else { + const incoming = sourceNodeData?.data.traces?.find(t => t.id.includes(originalStartNodeId)); + if (incoming) sourceHandle = `trace-${incoming.id}`; + else { + const anyMatch = sourceNodeData?.data.outgoingTraces?.find(t => t.id === `trace-${fromNodeId}`); + if (anyMatch) sourceHandle = `trace-${anyMatch.id}`; + } + } + } + } else { + const baseTraceId = currentTraceId.replace(/^trace-/, ''); + const matchingOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => t.id.replace(/^trace-/, '') === baseTraceId); + if (matchingOutgoing) { + sourceHandle = `trace-${matchingOutgoing.id}`; + } else { + const matchingIncoming = sourceNodeData?.data.traces?.find(t => t.id.replace(/^trace-/, '') === baseTraceId); + if (matchingIncoming) sourceHandle = `trace-${matchingIncoming.id}`; + } + } + + store.onConnect({ + source: fromNodeId, + sourceHandle, + target: targetNodeId, + targetHandle: 'input-0' + }); + setQuickChatNeedsDuplicate(false); + }, 50); + + // Update quick chat trace to point to new node + const currentId = quickChatTrace?.id || ''; + setQuickChatTrace(prev => prev ? { ...prev, sourceNodeId: targetNodeId } : null); + setQuickChatLastNodeId(targetNodeId); + } + + // ====== Now make API call and stream into targetNodeId ====== try { - // Build scopes for file search (Quick Chat uses a temp scope) const projectPath = currentBlueprintPath || 'untitled'; const scopes = [`${projectPath}/quick_chat_temp`]; - let fullResponse = ''; if (isCouncilSend && chairmanAtSend) { @@ -1458,7 +1599,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { break; case 'stage3_chunk': stage3Full += evt.data.chunk; - // Stream chairman response into chat setQuickChatMessages(prev => { const newMsgs = [...prev]; const lastMsg = newMsgs[newMsgs.length - 1]; @@ -1481,7 +1621,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { }; setQuickChatCouncilData(finalData); setQuickChatCouncilStage(''); - // Ensure final message is set setQuickChatMessages(prev => { const newMsgs = [...prev]; const lastMsg = newMsgs[newMsgs.length - 1]; @@ -1502,7 +1641,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { fullResponse = stage3Full; } else { // ========== SINGLE MODEL MODE ========== - // Determine provider const isClaude = modelAtSend.includes('claude'); const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3'; const provider = isClaude ? 'claude' : isOpenAI ? 'openai' : 'google'; @@ -1555,289 +1693,55 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { } } - // Determine whether to overwrite current node or create new one - // 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() !== ''; - - if (!fromNodeHasResponse && fromNode) { - // Overwrite the source node (it's empty) - const nodeUpdate: any = { - userPrompt: userInput, - response: fullResponse, - model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, - temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), - reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, - enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, - attachedFileIds: attachedFilesCopy, - status: 'success', - querySentAt: Date.now(), - responseReceivedAt: Date.now(), - }; - if (isCouncilSend) { - nodeUpdate.councilMode = true; - nodeUpdate.councilModels = councilModelsAtSend; - nodeUpdate.chairmanModel = chairmanAtSend; - nodeUpdate.councilData = quickChatCouncilData; - } - updateNodeData(fromNodeId, nodeUpdate); - - // 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); - - // Update last node ID - setQuickChatLastNodeId(fromNodeId); - - // 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 newNodeData: any = { - label: isCouncilSend ? 'Council Chat' : 'Quick Chat', - model: isCouncilSend ? chairmanAtSend!.model : modelAtSend, - temperature: isCouncilSend ? selectedNode.data.temperature : (reasoningModels.includes(modelAtSend) ? 1 : tempAtSend), - systemPrompt: '', - userPrompt: userInput, - mergeStrategy: 'smart' as const, - reasoningEffort: isCouncilSend ? (selectedNode.data.reasoningEffort || 'medium') : effortAtSend, - enableGoogleSearch: isCouncilSend ? (selectedNode.data.enableGoogleSearch !== false) : webSearchAtSend, - traces: [], - outgoingTraces: [], - forkedTraces: [], - mergedTraces: [], - activeTraceIds: [], - attachedFileIds: attachedFilesCopy, - response: fullResponse, - status: 'success' as const, - inputs: 1, - querySentAt: Date.now(), - responseReceivedAt: Date.now(), - }; - if (isCouncilSend) { - newNodeData.councilMode = true; - newNodeData.councilModels = councilModelsAtSend; - newNodeData.chairmanModel = chairmanAtSend; - newNodeData.councilData = quickChatCouncilData; - } - const newNode = { - id: newNodeId, - type: 'llmNode', - position: newPos, - data: { - ...newNodeData, - } - }; + // ====== Finalize: update target node with response ====== + const finalUpdate: any = { + response: fullResponse, + status: 'success', + responseReceivedAt: Date.now(), + }; + if (isCouncilSend) { + finalUpdate.councilData = quickChatCouncilData; + } + updateNodeData(targetNodeId, finalUpdate); + + // Update trace messages to include final response + setQuickChatTrace(prev => prev ? { + ...prev, + messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }] + } : null); - addNode(newNode); + // Generate title + generateTitle(targetNodeId, userInput, fullResponse); - // Connect to the source node + // For new nodes, update trace ID after onConnect has run + if (createdNewNode) { 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 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 => { - return t.id.includes(originalStartNodeId); - }); - - if (matchingTrace) { - sourceHandle = `trace-${matchingTrace.id}`; - } else { - // Fallback 1: Check INCOMING traces (Connect to Continue Handle) - const incoming = sourceNodeData?.data.traces?.find(t => - t.id.includes(originalStartNodeId) - ); - - if (incoming) { - // ID is preserved, so handle ID is just trace-{id} - sourceHandle = `trace-${incoming.id}`; - } else { - // Fallback 2: find any trace that ends with fromNodeId (unlikely if ID preserved) - const anyMatch = sourceNodeData?.data.outgoingTraces?.find( - t => t.id === `trace-${fromNodeId}` - ); - if (anyMatch) { - sourceHandle = `trace-${anyMatch.id}`; - } - } - } - } - } else { - // 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 === baseTraceId; // Exact match now - }); - - if (matchingOutgoing) { - sourceHandle = `trace-${matchingOutgoing.id}`; - } else { - // 2. Try INCOMING traces (Connect to Continue Handle) - const matchingIncoming = sourceNodeData?.data.traces?.find(t => { - const tId = t.id.replace(/^trace-/, ''); - return tId === baseTraceId; // Exact match now - }); - - if (matchingIncoming) { - // ID is preserved - sourceHandle = `trace-${matchingIncoming.id}`; - } - } - } - - // 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 newNode = store.nodes.find(n => n.id === targetNodeId); 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 - // 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 + const evolved = newNode.data.outgoingTraces.find(t => t.id === `${currentId}_${targetNodeId}`); + nextTraceId = evolved ? evolved.id : currentId; } else if (isCurrentNewTrace) { - // 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) - ); - if (match) nextTraceId = match.id; - } else { - // Regular trace: ID should be preserved - nextTraceId = currentId; + const startNodeId = currentId.replace('new-trace-', ''); + const match = newNode.data.outgoingTraces.find(t => t.id.includes(startNodeId)); + if (match) nextTraceId = match.id; } } - - setQuickChatTrace(prev => prev ? { - ...prev, - id: nextTraceId, - sourceNodeId: newNodeId, - 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); + + setQuickChatTrace(prev => prev ? { ...prev, id: nextTraceId } : null); + }, 200); } } catch (error) { console.error('Quick chat error:', error); + // Mark target node as error + updateNodeData(targetNodeId, { status: 'error' }); setQuickChatMessages(prev => [...prev, { id: `qc_err_${Date.now()}`, role: 'assistant', @@ -1847,7 +1751,6 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { } finally { setQuickChatLoading(false); setQuickChatCouncilStage(''); - // Refocus the input after sending setTimeout(() => { quickChatInputRef.current?.focus(); }, 50); @@ -2728,7 +2631,13 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (selectedNode.data.status !== 'loading' && activeTracesCheck.complete) { - handleRun(); + if (selectedNode.data.councilMode && (selectedNode.data.councilModels || []).length >= 2) { + handleRunCouncil(); + } else if (selectedNode.data.debateMode && (selectedNode.data.debateModels || []).length >= 2) { + handleRunDebate(); + } else { + handleRun(); + } } } // Shift+Enter allows normal newline |
