import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useReactFlow } from 'reactflow'; import useFlowStore from '../store/flowStore'; import { useAuthStore } from '../store/authStore'; import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore'; import type { Edge } from 'reactflow'; import ReactMarkdown from 'react-markdown'; import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } 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, theme, createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages, files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath, saveCurrentBlueprint } = useFlowStore(); const { getAuthHeader } = useAuthStore(); const { setCenter, getViewport } = useReactFlow(); 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 // Attachments state const [showAttachModal, setShowAttachModal] = useState(false); const [attachSearch, setAttachSearch] = useState(''); const settingsUploadRef = useRef(null); // 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 [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'); 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 [quickChatAttachedFiles, setQuickChatAttachedFiles] = useState([]); // File IDs for current message const [quickChatSentFiles, setQuickChatSentFiles] = useState<{msgId: string, fileIds: string[]}[]>([]); // Files sent with messages const [showQuickChatAttachModal, setShowQuickChatAttachModal] = useState(false); const [quickChatAttachSearch, setQuickChatAttachSearch] = useState(''); const [quickChatUploading, setQuickChatUploading] = useState(false); // Upload loading state const quickChatEndRef = useRef(null); const quickChatInputRef = useRef(null); const quickChatUploadRef = 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]); // Attachment helpers const handleAttach = async (fileId: string) => { if (!selectedNode) return; const current = selectedNode.data.attachedFileIds || []; if (!current.includes(fileId)) { updateNodeData(selectedNode.id, { attachedFileIds: [...current, fileId] }); // Add scope to file for filtering const projectPath = currentBlueprintPath || 'untitled'; const scope = `${projectPath}/${selectedNode.id}`; try { await addFileScope(fileId, scope); } catch (e) { console.error('Failed to add file scope:', e); } // Auto-save blueprint to persist attached files if (currentBlueprintPath) { saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error); } } setShowAttachModal(false); }; const handleDetach = async (fileId: string) => { if (!selectedNode) return; const current = selectedNode.data.attachedFileIds || []; updateNodeData(selectedNode.id, { attachedFileIds: current.filter(id => id !== fileId) }); // Remove scope from file const projectPath = currentBlueprintPath || 'untitled'; const scope = `${projectPath}/${selectedNode.id}`; try { await removeFileScope(fileId, scope); } catch (e) { console.error('Failed to remove file scope:', e); } // Auto-save blueprint to persist detached files if (currentBlueprintPath) { saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error); } }; const handleUploadAndAttach = async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0 || !selectedNode) return; const file = e.target.files[0]; try { // Determine provider based on node model const model = selectedNode.data.model; let provider: 'local' | 'openai' | 'google' = 'local'; if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) { provider = 'openai'; } else if (model.startsWith('gemini')) { provider = 'google'; } const meta = await uploadFile(file, { provider }); handleAttach(meta.id); } catch (err) { alert(`Upload failed: ${(err as Error).message}`); } finally { e.target.value = ''; } }; // Filter files for attach modal const filteredFilesToAttach = useMemo(() => { const q = attachSearch.trim().toLowerCase(); if (!q) return files; return files.filter(f => f.name.toLowerCase().includes(q)); }, [files, attachSearch]); // Filter files for Quick Chat attach modal const filteredQuickChatFiles = useMemo(() => { const q = quickChatAttachSearch.trim().toLowerCase(); if (!q) return files; return files.filter(f => f.name.toLowerCase().includes(q)); }, [files, quickChatAttachSearch]); 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); // Calculate scopes: all nodes in the current trace path const projectPath = currentBlueprintPath || 'untitled'; // Compute all upstream node IDs by traversing edges backward const traceNodeIds = new Set(); traceNodeIds.add(runningNodeId); const visited = new Set(); const queue = [runningNodeId]; while (queue.length > 0) { const currentNodeId = queue.shift()!; if (visited.has(currentNodeId)) continue; visited.add(currentNodeId); // Find all incoming edges to this node const incomingEdges = edges.filter(e => e.target === currentNodeId); for (const edge of incomingEdges) { const sourceNodeId = edge.source; if (!visited.has(sourceNodeId)) { traceNodeIds.add(sourceNodeId); queue.push(sourceNodeId); } } } // Build scopes for all nodes in the trace path const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`); console.log('[file_search] trace scopes:', scopes); // If no prompt but has files, use a default prompt const attachedFiles = selectedNode.data.attachedFileIds || []; const effectivePrompt = runningPrompt?.trim() ? runningPrompt : attachedFiles.length > 0 ? 'Please analyze the attached files.' : ''; try { const response = await fetch('/api/run_node_stream', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ node_id: runningNodeId, incoming_contexts: [{ messages: context }], user_prompt: effectivePrompt, attached_file_ids: attachedFiles, scopes, 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) { console.log('[stream] done, fullResponse length:', fullResponse.length); break; } const chunk = decoder.decode(value); console.log('[stream] received chunk:', chunk.substring(0, 50)); 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('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, 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('/api/generate_title', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, 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('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, 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; // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a) const getNodeIdFromMsgId = (msgId: string): string | null => { if (!msgId) return null; const parts = msgId.split('-'); if (parts.length >= 2) { // Remove last part (-u or -a) and rejoin return parts.slice(0, -1).join('-'); } return null; }; // Helper to build sentFiles from messages const buildSentFilesFromMessages = (messages: Message[]): {msgId: string, fileIds: string[]}[] => { const sentFiles: {msgId: string, fileIds: string[]}[] = []; for (const msg of messages) { if (msg.role === 'user' && msg.id) { const nodeId = getNodeIdFromMsgId(msg.id); if (nodeId) { const node = nodes.find(n => n.id === nodeId); if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) { sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds }); } } } } return sentFiles; }; 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); setQuickChatSentFiles(buildSentFilesFromMessages(initialMessages)); setQuickChatNeedsDuplicate(false); setQuickChatLastNodeId(selectedNode.id); } 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); setQuickChatSentFiles(buildSentFilesFromMessages(fullMessages)); // Always set last node ID to current selected node // handleQuickChatSend will decide whether to overwrite (if empty) or create new node (if has response) setQuickChatLastNodeId(selectedNode.id); } 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([]); setQuickChatAttachedFiles([]); setQuickChatSentFiles([]); }; // Quick Chat file attachment helpers const getQuickChatScope = () => { const projectPath = currentBlueprintPath || 'untitled'; return `${projectPath}/quick_chat_temp`; }; const handleQuickChatAttach = async (fileId: string) => { if (!quickChatAttachedFiles.includes(fileId)) { setQuickChatAttachedFiles(prev => [...prev, fileId]); // Add scope to file for filtering try { await addFileScope(fileId, getQuickChatScope()); } catch (e) { console.error('Failed to add file scope:', e); } } setShowQuickChatAttachModal(false); }; const handleQuickChatDetach = async (fileId: string) => { setQuickChatAttachedFiles(prev => prev.filter(id => id !== fileId)); // Remove scope from file try { await removeFileScope(fileId, getQuickChatScope()); } catch (e) { console.error('Failed to remove file scope:', e); } }; const handleQuickChatUpload = async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; const file = e.target.files[0]; setQuickChatUploading(true); try { const meta = await uploadFile(file, { provider: 'local' }); setQuickChatAttachedFiles(prev => [...prev, meta.id]); // Add scope to file for filtering try { await addFileScope(meta.id, getQuickChatScope()); } catch (e) { console.error('Failed to add file scope:', e); } } catch (err) { alert(`Upload failed: ${(err as Error).message}`); } finally { e.target.value = ''; setQuickChatUploading(false); } }; // 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; // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a) const getNodeIdFromMsgId = (msgId: string): string | null => { if (!msgId) return null; const parts = msgId.split('-'); if (parts.length >= 2) { return parts.slice(0, -1).join('-'); } return null; }; // 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 }); } // Build sentFiles from messages const sentFiles: {msgId: string, fileIds: string[]}[] = []; for (const msg of fullMessages) { if (msg.role === 'user' && msg.id) { const nodeId = getNodeIdFromMsgId(msg.id); if (nodeId) { const node = nodes.find(n => n.id === nodeId); if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) { sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds }); } } } } // Create a pseudo-trace for the merged context setQuickChatTrace({ id: merged.id, sourceNodeId: selectedNode.id, color: merged.colors[0] || '#888', messages: fullMessages }); setQuickChatMessages(fullMessages); setQuickChatSentFiles(sentFiles); 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; }; // Helper: find incoming edge for a given trace ID (with fallbacks) const findIncomingEdgeForTrace = (nodeId: string, traceId: string): Edge | null => { // 1) exact match by sourceHandle let edge = edges.find(e => e.target === nodeId && e.sourceHandle === `trace-${traceId}`); if (edge) return edge; // 2) fallback: any incoming edge whose source has this trace in outgoingTraces edge = edges.find(e => { if (e.target !== nodeId) return false; const src = nodes.find(n => n.id === e.source); return src?.data.outgoingTraces?.some((t: Trace) => t.id === traceId); }); return edge || null; }; // Helper: get source trace IDs for a merged trace on a given node (supports propagated merged traces) const getMergedSourceIds = (nodeId: string, traceId: string): string[] => { const node = nodes.find(n => n.id === nodeId); if (!node) return []; const mergedLocal = node.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId); if (mergedLocal) return mergedLocal.sourceTraceIds || []; const incomingMatch = node.data.traces?.find((t: Trace) => t.id === traceId); if (incomingMatch?.isMerged && incomingMatch.sourceTraceIds) return incomingMatch.sourceTraceIds; const outgoingMatch = node.data.outgoingTraces?.find((t: Trace) => t.id === traceId); if (outgoingMatch?.isMerged && outgoingMatch.sourceTraceIds) return outgoingMatch.sourceTraceIds; return []; }; // Recursive: Check if specific trace path upstream has complete nodes (supports multi-level merged) const checkTracePathComplete = ( nodeId: string, traceId: string, visited: Set = new Set() ): boolean => { const visitKey = `${nodeId}-${traceId}`; if (visited.has(visitKey)) return true; visited.add(visitKey); // Determine if this node is the merge owner or just receiving a propagated merged trace const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId); const localParents = getMergedSourceIds(nodeId, traceId); const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId); if (!incomingEdge) { // If no incoming edge and this node owns the merge, check parents from here if (localMerge && localParents.length > 0) { for (const pid of localParents) { if (!checkTracePathComplete(nodeId, pid, visited)) return false; } return true; } return true; // head } const sourceNode = nodes.find(n => n.id === incomingEdge.source); if (!sourceNode || sourceNode.data.disabled) return true; // If merged at sourceNode (or propagated merged), recurse into each parent from the merge owner const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId); if (parentIds.length > 0) { const mergeOwnerId = localMerge ? nodeId : sourceNode.id; for (const pid of parentIds) { if (!checkTracePathComplete(mergeOwnerId, pid, visited)) return false; } return true; } // Regular trace: check node content then continue upstream if (!sourceNode.data.userPrompt || !sourceNode.data.response) return false; return checkTracePathComplete(sourceNode.id, traceId, visited); }; // Recursive: Find the first empty node on a specific trace path (supports multi-level merged) const findEmptyNodeOnTrace = ( nodeId: string, traceId: string, visited: Set = new Set() ): string | null => { const visitKey = `${nodeId}-${traceId}`; if (visited.has(visitKey)) return null; visited.add(visitKey); // Determine if this node owns the merge or just receives propagated merged trace const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId); const localParents = getMergedSourceIds(nodeId, traceId); const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId); if (!incomingEdge) { if (localMerge && localParents.length > 0) { for (const pid of localParents) { const upstreamEmpty = findEmptyNodeOnTrace(nodeId, pid, visited); if (upstreamEmpty) return upstreamEmpty; } } return null; } const sourceNode = nodes.find(n => n.id === incomingEdge.source); if (!sourceNode || sourceNode.data.disabled) return null; const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId); if (parentIds.length > 0) { const mergeOwnerId = localMerge ? nodeId : sourceNode.id; for (const pid of parentIds) { const upstreamEmpty = findEmptyNodeOnTrace(mergeOwnerId, pid, visited); if (upstreamEmpty) return upstreamEmpty; } } if (!sourceNode.data.userPrompt || !sourceNode.data.response) { return sourceNode.id; } return findEmptyNodeOnTrace(sourceNode.id, traceId, visited); }; // Check if all active traces are complete (for main Run Node button) const checkActiveTracesComplete = (): { complete: boolean; incompleteTraceId?: string } => { if (!selectedNode) return { complete: true }; const activeTraceIds = selectedNode.data.activeTraceIds || []; if (activeTraceIds.length === 0) return { complete: true }; // Check upstream nodes ONLY for active traces (supports merged trace recursion) for (const traceId of activeTraceIds) { if (!checkTracePathComplete(selectedNode.id, traceId)) { return { complete: false, incompleteTraceId: 'upstream' }; } } // Check incoming traces content (message integrity) 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 merged traces content (including propagated merged traces) for (const traceId of activeTraceIds) { const sourceIds = getMergedSourceIds(selectedNode.id, traceId); if (sourceIds.length > 0) { for (const sourceId of sourceIds) { const sourceTrace = incomingTraces.find((t: Trace) => t.id === sourceId); if (sourceTrace && !isTraceComplete(sourceTrace)) { return { complete: false, incompleteTraceId: sourceId }; } } } } 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) { const 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 }; const handleQuickChatSend = async () => { // Allow send if there's text OR attached files const hasContent = quickChatInput.trim() || quickChatAttachedFiles.length > 0; if (!hasContent || !quickChatTrace || quickChatLoading || !selectedNode) return; const userInput = quickChatInput; const attachedFilesCopy = [...quickChatAttachedFiles]; const msgId = `qc_${Date.now()}_u`; const userMessage: Message = { id: msgId, role: 'user', content: userInput || '[Files attached]' }; // Track sent files for display if (attachedFilesCopy.length > 0) { setQuickChatSentFiles(prev => [...prev, { msgId, fileIds: attachedFilesCopy }]); } // Add user message to display const messagesBeforeSend = [...quickChatMessages]; setQuickChatMessages(prev => [...prev, userMessage]); setQuickChatInput(''); setQuickChatAttachedFiles([]); // Clear attached files after send 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); // Build scopes for file search (Quick Chat uses a temp scope) const projectPath = currentBlueprintPath || 'untitled'; const scopes = [`${projectPath}/quick_chat_temp`]; // Call LLM API with current messages as context const response = await fetch('/api/run_node_stream', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, body: JSON.stringify({ node_id: 'quick_chat_temp', incoming_contexts: [{ messages: messagesBeforeSend }], user_prompt: userInput || 'Please analyze the attached files.', attached_file_ids: attachedFilesCopy, scopes, 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 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) updateNodeData(fromNodeId, { userPrompt: userInput, response: fullResponse, model: modelAtSend, temperature: isReasoning ? 1 : tempAtSend, reasoningEffort: effortAtSend, enableGoogleSearch: webSearchAtSend, attachedFileIds: attachedFilesCopy, 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); // 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 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: [], attachedFileIds: attachedFilesCopy, 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 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 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 } 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; } } 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); } } 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.isMerged ? ( // Merged Trace Rendering (for propagated merged traces)
{(trace.mergedColors || [trace.color]).slice(0, 3).map((color, idx) => (
))} {(trace.mergedColors?.length || 0) > 3 && (
+
)}
) : ( // Regular Trace Rendering
)} {trace.isMerged ? 'Merged ' : ''}#{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); // 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 (
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)} {!isComplete && ( (incomplete) )}
{merged.strategy} • {merged.messages.length} msgs
{/* Quick Chat for Merged Trace */}
); })}
)}