diff options
| author | blackhao <13851610112@163.com> | 2025-12-08 15:07:12 -0600 |
|---|---|---|
| committer | blackhao <13851610112@163.com> | 2025-12-08 15:07:12 -0600 |
| commit | f97b7a1bfa220a0947f2cd63c23f4faa9fcd42e7 (patch) | |
| tree | d1d6eb9e5196afb7bac8a22d0d7587aedcada450 /frontend/src/components | |
| parent | 93dbe11014cf967690727c25e89d9d1075008c24 (diff) | |
merge logic
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/ContextMenu.tsx | 22 | ||||
| -rw-r--r-- | frontend/src/components/LeftSidebar.tsx | 71 | ||||
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 1576 | ||||
| -rw-r--r-- | frontend/src/components/edges/MergedEdge.tsx | 77 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 259 |
5 files changed, 1868 insertions, 137 deletions
diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 459641b..8104f8c 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import useFlowStore from '../store/flowStore'; interface ContextMenuProps { x: number; @@ -8,16 +9,31 @@ interface ContextMenuProps { } export const ContextMenu: React.FC<ContextMenuProps> = ({ x, y, items, onClose }) => { + const { theme } = useFlowStore(); + const isDark = theme === 'dark'; + return ( <div - className="fixed z-50 bg-white border border-gray-200 shadow-lg rounded-md py-1 min-w-[150px]" + className={`fixed z-50 shadow-lg rounded-md py-1 min-w-[150px] ${ + isDark + ? 'bg-gray-800 border border-gray-600' + : 'bg-white border border-gray-200' + }`} style={{ top: y, left: x }} - onClick={(e) => e.stopPropagation()} // Prevent click through + onClick={(e) => e.stopPropagation()} > {items.map((item, idx) => ( <button key={idx} - className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 ${item.danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700'}`} + className={`w-full text-left px-4 py-2 text-sm transition-colors ${ + item.danger + ? isDark + ? 'text-red-400 hover:bg-red-900/50' + : 'text-red-600 hover:bg-red-50' + : isDark + ? 'text-gray-200 hover:bg-gray-700' + : 'text-gray-700 hover:bg-gray-100' + }`} onClick={() => { item.onClick(); onClose(); diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index fa8b471..1eaa62c 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -9,7 +9,8 @@ interface LeftSidebarProps { const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project'); - const { archivedNodes, removeFromArchive, createNodeFromArchive } = useFlowStore(); + const { archivedNodes, removeFromArchive, createNodeFromArchive, theme } = useFlowStore(); + const isDark = theme === 'dark'; const handleDragStart = (e: React.DragEvent, archiveId: string) => { e.dataTransfer.setData('archiveId', archiveId); @@ -18,61 +19,79 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { if (!isOpen) { return ( - <div className="border-r border-gray-200 h-screen bg-white flex flex-col items-center py-4 w-12 z-10 transition-all duration-300"> + <div className={`border-r h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' + }`}> <button onClick={onToggle} - className="p-2 hover:bg-gray-100 rounded mb-4" + className={`p-2 rounded mb-4 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`} title="Expand" > - <ChevronRight size={20} className="text-gray-500" /> + <ChevronRight size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> </button> {/* Icons when collapsed */} <div className="flex flex-col gap-4"> - <Folder size={20} className={activeTab === 'project' ? "text-blue-500" : "text-gray-400"} /> - <FileText size={20} className={activeTab === 'files' ? "text-blue-500" : "text-gray-400"} /> - <Archive size={20} className={activeTab === 'archive' ? "text-blue-500" : "text-gray-400"} /> + <Folder size={20} className={activeTab === 'project' ? "text-blue-500" : isDark ? "text-gray-500" : "text-gray-400"} /> + <FileText size={20} className={activeTab === 'files' ? "text-blue-500" : isDark ? "text-gray-500" : "text-gray-400"} /> + <Archive size={20} className={activeTab === 'archive' ? "text-blue-500" : isDark ? "text-gray-500" : "text-gray-400"} /> </div> </div> ); } return ( - <div className="w-64 border-r border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10 transition-all duration-300"> + <div className={`w-64 border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' + }`}> {/* Header */} - <div className="p-3 border-b border-gray-200 flex justify-between items-center bg-gray-50"> - <h2 className="font-bold text-sm text-gray-700 uppercase">Workspace</h2> + <div className={`p-3 border-b flex justify-between items-center ${ + isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50' + }`}> + <h2 className={`font-bold text-sm uppercase ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Workspace</h2> <button onClick={onToggle} - className="p-1 hover:bg-gray-200 rounded" + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} > - <ChevronLeft size={16} className="text-gray-500" /> + <ChevronLeft size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> </button> </div> {/* Tabs */} - <div className="flex border-b border-gray-200"> + <div className={`flex border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> <button onClick={() => setActiveTab('project')} - className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${activeTab === 'project' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${ + activeTab === 'project' + ? 'border-b-2 border-blue-500 text-blue-500 font-medium' + : isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-50' + }`} > <Folder size={14} /> Project </button> <button onClick={() => setActiveTab('files')} - className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${activeTab === 'files' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${ + activeTab === 'files' + ? 'border-b-2 border-blue-500 text-blue-500 font-medium' + : isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-50' + }`} > <FileText size={14} /> Files </button> <button onClick={() => setActiveTab('archive')} - className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${activeTab === 'archive' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} + className={`flex-1 p-3 text-xs flex justify-center items-center gap-2 ${ + activeTab === 'archive' + ? 'border-b-2 border-blue-500 text-blue-500 font-medium' + : isDark ? 'text-gray-400 hover:bg-gray-700' : 'text-gray-600 hover:bg-gray-50' + }`} > <Archive size={14} /> Archive </button> </div> {/* Content Area */} - <div className="flex-1 overflow-y-auto p-4 text-sm text-gray-500"> + <div className={`flex-1 overflow-y-auto p-4 text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> {activeTab === 'project' && ( <div className="flex flex-col items-center justify-center h-full opacity-50"> <Folder size={48} className="mb-2" /> @@ -97,28 +116,34 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => { </div> ) : ( <> - <p className="text-xs text-gray-400 mb-2">Drag to canvas to create a copy</p> + <p className={`text-xs mb-2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>Drag to canvas to create a copy</p> {archivedNodes.map((archived) => ( <div key={archived.id} draggable onDragStart={(e) => handleDragStart(e, archived.id)} - className="p-2 bg-gray-50 border border-gray-200 rounded-md cursor-grab hover:bg-gray-100 hover:border-gray-300 transition-colors group" + className={`p-2 border rounded-md cursor-grab transition-colors group ${ + isDark + ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 hover:border-gray-500' + : 'bg-gray-50 border-gray-200 hover:bg-gray-100 hover:border-gray-300' + }`} > <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> - <MessageSquare size={14} className="text-gray-500" /> - <span className="text-sm font-medium truncate max-w-[140px]">{archived.label}</span> + <MessageSquare size={14} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + <span className={`text-sm font-medium truncate max-w-[140px] ${isDark ? 'text-gray-200' : ''}`}>{archived.label}</span> </div> <button onClick={() => removeFromArchive(archived.id)} - className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-100 rounded text-gray-400 hover:text-red-500 transition-all" + className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${ + isDark ? 'hover:bg-red-900 text-gray-400 hover:text-red-400' : 'hover:bg-red-100 text-gray-400 hover:text-red-500' + }`} title="Remove from archive" > <Trash2 size={12} /> </button> </div> - <div className="text-[10px] text-gray-400 mt-1">{archived.model}</div> + <div className={`text-[10px] mt-1 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>{archived.model}</div> </div> ))} </> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 165028c..28a40f6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,18 +1,25 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import useFlowStore from '../store/flowStore'; -import type { NodeData } 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 } from 'lucide-react'; +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<SidebarProps> = ({ isOpen, onToggle }) => { - const { nodes, selectedNodeId, updateNodeData, getActiveContext } = useFlowStore(); +const Sidebar: React.FC<SidebarProps> = ({ 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<string | null>(null); // Track which node is streaming // Response Modal & Edit states const [isModalOpen, setIsModalOpen] = useState(false); @@ -23,6 +30,29 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { 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<Trace | null>(null); + const [quickChatMessages, setQuickChatMessages] = useState<Message[]>([]); + 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<HTMLDivElement>(null); + const quickChatInputRef = useRef<HTMLTextAreaElement>(null); + + // Merge Trace states + const [showMergeModal, setShowMergeModal] = useState(false); + const [mergeSelectedIds, setMergeSelectedIds] = useState<string[]>([]); + const [mergeStrategy, setMergeStrategy] = useState<MergeStrategy>('query_time'); + const [mergeDraggedId, setMergeDraggedId] = useState<string | null>(null); + const [mergeOrder, setMergeOrder] = useState<string[]>([]); + const [showMergePreview, setShowMergePreview] = useState(false); + const [isSummarizingMerge, setIsSummarizingMerge] = useState(false); const selectedNode = nodes.find((n) => n.id === selectedNodeId); @@ -31,8 +61,23 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { 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) { @@ -40,18 +85,27 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { } }, [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 ( - <div className="border-l border-gray-200 h-screen bg-white flex flex-col items-center py-4 w-12 z-10 transition-all duration-300"> + <div className={`border-l h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' + }`}> <button onClick={onToggle} - className="p-2 hover:bg-gray-100 rounded mb-4" + className={`p-2 rounded mb-4 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`} title="Expand" > - <ChevronLeft size={20} className="text-gray-500" /> + <ChevronLeft size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> </button> {selectedNode && ( - <div className="writing-vertical text-xs font-bold text-gray-500 uppercase tracking-widest mt-4" style={{ writingMode: 'vertical-rl' }}> + <div className={`writing-vertical text-xs font-bold uppercase tracking-widest mt-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} style={{ writingMode: 'vertical-rl' }}> {selectedNode.data.label} </div> )} @@ -61,15 +115,21 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { if (!selectedNode) { return ( - <div className="w-96 border-l border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10 transition-all duration-300"> - <div className="p-3 border-b border-gray-200 flex justify-between items-center bg-gray-50"> - <span className="text-sm font-medium text-gray-500">Details</span> - <button onClick={onToggle} className="p-1 hover:bg-gray-200 rounded"> - <ChevronRight size={16} className="text-gray-500" /> + <div className={`w-96 border-l h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' + }`}> + <div className={`p-3 border-b flex justify-between items-center ${ + isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50' + }`}> + <span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Details</span> + <button onClick={onToggle} className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}> + <ChevronRight size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> </button> </div> - <div className="flex-1 p-4 bg-gray-50 text-gray-500 text-center flex flex-col justify-center"> - <p>Select a node to edit</p> + <div className={`flex-1 p-4 text-center flex flex-col justify-center ${ + isDark ? 'bg-gray-900 text-gray-400' : 'bg-gray-50 text-gray-500' + }`}> + <p>Select a node to edit</p> </div> </div> ); @@ -78,20 +138,34 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { const handleRun = async () => { if (!selectedNode) return; - updateNodeData(selectedNode.id, { status: 'loading', response: '' }); + // 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(selectedNode.id); + 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: selectedNode.id, - incoming_contexts: [{ messages: context }], // Simple list wrap for now - user_prompt: selectedNode.data.userPrompt, + 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', @@ -99,8 +173,8 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { temperature: selectedNode.data.temperature, system_prompt: selectedNode.data.systemPrompt, api_key: selectedNode.data.apiKey, - enable_google_search: selectedNode.data.enableGoogleSearch !== false, // Default true - reasoning_effort: selectedNode.data.reasoningEffort || 'medium', // For reasoning models + enable_google_search: selectedNode.data.enableGoogleSearch !== false, + reasoning_effort: selectedNode.data.reasoningEffort || 'medium', } }) }); @@ -115,17 +189,15 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { if (done) break; const chunk = decoder.decode(value); fullResponse += chunk; + // Only update stream buffer, the display logic will check streamingNodeId setStreamBuffer(prev => prev + chunk); - // We update the store less frequently or at the end to avoid too many re-renders - // But for "live" feel we might want to update local state `streamBuffer` and sync to store at end } - // Update final state - // Append the new interaction to the node's output messages + // Update final state using captured nodeId const newUserMsg = { id: `msg_${Date.now()}_u`, role: 'user', - content: selectedNode.data.userPrompt + content: runningPrompt }; const newAssistantMsg = { id: `msg_${Date.now()}_a`, @@ -133,19 +205,23 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { content: fullResponse }; - updateNodeData(selectedNode.id, { + const responseReceivedAt = Date.now(); + + updateNodeData(runningNodeId, { status: 'success', response: fullResponse, + responseReceivedAt, messages: [...context, newUserMsg, newAssistantMsg] as any }); - // Auto-generate title using gpt-5-nano (async, non-blocking) - // Always regenerate title after each query - generateTitle(selectedNode.id, selectedNode.data.userPrompt, fullResponse); + // Auto-generate title + generateTitle(runningNodeId, runningPrompt, fullResponse); } catch (error) { console.error(error); - updateNodeData(selectedNode.id, { status: 'error' }); + updateNodeData(runningNodeId, { status: 'error' }); + } finally { + setStreamingNodeId(prev => prev === runningNodeId ? null : prev); } }; @@ -216,33 +292,730 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { } }; + // 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<string> = 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 ( - <div className="w-96 border-l border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10 transition-all duration-300"> + <div + className={`w-96 border-l h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white' + }`} + onClick={onInteract} + > {/* Header */} - <div className="p-4 border-b border-gray-200 bg-gray-50 flex flex-col gap-2"> + <div className={`p-4 border-b flex flex-col gap-2 ${ + isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50' + }`}> <div className="flex justify-between items-center"> <input type="text" value={selectedNode.data.label} onChange={(e) => handleChange('label', e.target.value)} - className="font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full" + className={`font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full ${ + isDark ? 'text-gray-200' : 'text-gray-900' + }`} /> - <button onClick={onToggle} className="p-1 hover:bg-gray-200 rounded shrink-0"> - <ChevronRight size={16} className="text-gray-500" /> + <button onClick={onToggle} className={`p-1 rounded shrink-0 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}> + <ChevronRight size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> </button> </div> <div className="flex items-center justify-between mt-1"> - <div className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded uppercase"> - {selectedNode.data.status} + <div className={`text-xs px-2 py-1 rounded uppercase ${ + isDark ? 'bg-blue-900 text-blue-300' : 'bg-blue-100 text-blue-700' + }`}> + {selectedNode.data.status} </div> - <div className="text-xs text-gray-500"> + <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> ID: {selectedNode.id} </div> </div> </div> {/* Tabs */} - <div className="flex border-b border-gray-200"> + <div className={`flex border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> <button onClick={() => setActiveTab('interact')} className={`flex-1 p-3 text-sm flex justify-center items-center gap-2 ${activeTab === 'interact' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`} @@ -309,20 +1082,96 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { </select> </div> - {/* Trace Selector */} - {selectedNode.data.traces && selectedNode.data.traces.length > 0 && ( - <div className="bg-gray-50 p-2 rounded border border-gray-200"> - <label className="block text-xs font-bold text-gray-500 mb-2 uppercase">Select Context Traces</label> + {/* Trace Selector - Single Select */} + <div className={`p-2 rounded border ${isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'}`}> + <div className="flex items-center justify-between mb-2"> + <label className={`block text-xs font-bold uppercase ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Select Context + </label> + {/* Create Merged Trace Button - only show if 2+ traces */} + {selectedNode.data.traces && selectedNode.data.traces.length >= 2 && ( + <button + onClick={openMergeModal} + className={`text-xs px-2 py-1 rounded flex items-center gap-1 ${ + isDark + ? 'bg-purple-900 hover:bg-purple-800 text-purple-300' + : 'bg-purple-100 hover:bg-purple-200 text-purple-600' + }`} + > + <GitMerge size={12} /> + Merge + </button> + )} + </div> + + {/* New Trace option */} + <div className={`flex items-center gap-2 text-sm p-1 rounded group mb-1 border-b pb-2 ${ + isDark ? 'hover:bg-gray-800 border-gray-700' : 'hover:bg-white border-gray-200' + }`}> + <div className="flex items-center gap-2 flex-1"> + <div className="w-2 h-2 rounded-full bg-gray-400"></div> + <span className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Start New Trace</span> + </div> + <button + onClick={(e) => { + e.stopPropagation(); + openQuickChat(null, true); + }} + className={`p-1 rounded transition-all ${ + isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-100 text-gray-400 hover:text-blue-600' + }`} + title="Start New Trace Quick Chat" + > + <MessageCircle size={14} /> + </button> + </div> + + {/* 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<string, Trace>(); + // 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 ( <div className="space-y-1 max-h-[150px] overflow-y-auto"> - {selectedNode.data.traces.map((trace) => { + {allTraces.map((trace: Trace) => { const isActive = selectedNode.data.activeTraceIds?.includes(trace.id); + const isComplete = canQuickChat(trace); + return ( - <div key={trace.id} className="flex items-start gap-2 text-sm p-1 hover:bg-white rounded cursor-pointer" - onClick={() => { - const current = selectedNode.data.activeTraceIds || []; - const next = [trace.id]; // Single select mode - handleChange('activeTraceIds', next); - }} + <div + key={trace.id} + onClick={() => 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' + }`} > <input type="radio" @@ -330,39 +1179,178 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { readOnly className="mt-1" /> + <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div> - <span className="font-mono text-xs text-gray-400">#{trace.id.slice(-4)}</span> - </div> - <div className="text-xs text-gray-600 truncate"> - From Node: {trace.sourceNodeId} - </div> - <div className="text-[10px] text-gray-400"> - {trace.messages.length} msgs - </div> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div> + <span className={`font-mono text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>#{trace.id.slice(-4)}</span> + {!isComplete && ( + <span className="text-[9px] text-orange-500">(incomplete)</span> + )} + </div> + <div className={`text-[10px] ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + {trace.messages.length} msgs + </div> </div> + + {/* 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 ( + <button + onClick={(e) => { + e.stopPropagation(); + openQuickChat(trace, false); + }} + disabled={!isComplete} + className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${ + isComplete + ? hasDownstream + ? isDark ? 'hover:bg-orange-900 text-gray-500 hover:text-orange-400' : 'hover:bg-orange-100 text-gray-400 hover:text-orange-600' + : isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-100 text-gray-400 hover:text-blue-600' + : 'text-gray-500 cursor-not-allowed' + }`} + title={isComplete ? buttonLabel : "Trace incomplete - all nodes need Q&A"} + > + <MessageCircle size={14} /> + </button> + ); + })()} </div> ); })} </div> - </div> - )} + ); + })()} + + {/* Merged Traces - also single selectable */} + {selectedNode.data.mergedTraces && selectedNode.data.mergedTraces.length > 0 && ( + <div className={`mt-2 pt-2 border-t space-y-1 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <label className={`block text-[10px] font-bold uppercase mb-1 ${isDark ? 'text-purple-400' : 'text-purple-600'}`}> + Merged Traces + </label> + {selectedNode.data.mergedTraces.map((merged: MergedTrace) => { + const isActive = selectedNode.data.activeTraceIds?.includes(merged.id); + + return ( + <div + key={merged.id} + onClick={() => 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' + }`} + > + <input + type="radio" + checked={isActive || false} + readOnly + className="shrink-0" + /> + + {/* Alternating color indicator */} + <div className="flex -space-x-1 shrink-0"> + {merged.colors.slice(0, 3).map((color, idx) => ( + <div + key={idx} + className="w-3 h-3 rounded-full border-2" + style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }} + /> + ))} + {merged.colors.length > 3 && ( + <div className={`w-3 h-3 rounded-full flex items-center justify-center text-[8px] ${ + isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500' + }`}> + +{merged.colors.length - 3} + </div> + )} + </div> + + <div className="flex-1 min-w-0"> + <div className={`font-mono truncate ${isDark ? 'text-gray-300' : 'text-gray-600'}`}> + Merged #{merged.id.slice(-6)} + </div> + <div className={`truncate ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + {merged.strategy} • {merged.messages.length} msgs + </div> + </div> + + {/* Quick Chat for Merged Trace */} + <button + onClick={(e) => { + e.stopPropagation(); + openMergedQuickChat(merged); + }} + className={`p-1 rounded shrink-0 ${ + isDark ? 'hover:bg-purple-900 text-gray-500 hover:text-purple-400' : 'hover:bg-purple-100 text-gray-400 hover:text-purple-600' + }`} + title="Quick Chat with merged context" + > + <MessageCircle size={12} /> + </button> + + <button + onClick={(e) => { + e.stopPropagation(); + deleteMergedTrace(selectedNode.id, merged.id); + }} + className={`p-1 rounded shrink-0 ${ + isDark ? 'hover:bg-red-900 text-gray-500 hover:text-red-400' : 'hover:bg-red-50 text-gray-400 hover:text-red-600' + }`} + title="Delete merged trace" + > + <Trash2 size={12} /> + </button> + </div> + ); + })} + </div> + )} + </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label> <textarea value={selectedNode.data.userPrompt} onChange={(e) => handleChange('userPrompt', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (selectedNode.data.status !== 'loading' && activeTracesCheck.complete) { + handleRun(); + } + } + // Shift+Enter allows normal newline + }} className="w-full border border-gray-300 rounded-md p-2 text-sm min-h-[100px]" - placeholder="Type your message here..." + placeholder="Type your message here... (Enter to run, Shift+Enter for newline)" /> </div> + {/* Warning for incomplete upstream traces */} + {!activeTracesCheck.complete && ( + <div className={`mb-2 p-2 rounded-md text-xs flex items-center gap-2 ${ + isDark ? 'bg-yellow-900/50 text-yellow-300 border border-yellow-700' : 'bg-yellow-50 text-yellow-700 border border-yellow-200' + }`}> + <AlertCircle size={14} /> + <span>Upstream node is empty. Complete the context chain before running.</span> + </div> + )} + <button onClick={handleRun} - disabled={selectedNode.data.status === 'loading'} - className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-blue-300 flex items-center justify-center gap-2" + disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete} + className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${ + selectedNode.data.status === 'loading' || !activeTracesCheck.complete + ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400' + : 'bg-blue-600 text-white hover:bg-blue-700' + }`} > {selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />} Run Node @@ -398,7 +1386,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { </button> </> )} - </div> + </div> </div> {isEditing ? ( @@ -424,8 +1412,12 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { </div> </div> ) : ( - <div className="bg-gray-50 p-3 rounded-md border border-gray-200 min-h-[150px] text-sm prose prose-sm max-w-none"> - <ReactMarkdown>{selectedNode.data.response || streamBuffer}</ReactMarkdown> + <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${ + isDark + ? 'bg-gray-900 border-gray-700 prose-invert' + : 'bg-gray-50 border-gray-200' + }`}> + <ReactMarkdown>{selectedNode.data.response || (streamingNodeId === selectedNode.id ? streamBuffer : '')}</ReactMarkdown> </div> )} </div> @@ -539,15 +1531,43 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { {activeTab === 'debug' && ( <div className="space-y-4"> + {/* Timestamps */} + <div className="bg-gray-50 p-3 rounded border border-gray-200"> + <label className="block text-xs font-bold text-gray-500 mb-2 uppercase">Timestamps</label> + <div className="grid grid-cols-2 gap-2 text-xs"> + <div> + <span className="text-gray-500">Query Sent:</span> + <div className="font-mono text-gray-700"> + {selectedNode.data.querySentAt + ? new Date(selectedNode.data.querySentAt).toLocaleString() + : '-'} + </div> + </div> + <div> + <span className="text-gray-500">Response Received:</span> + <div className="font-mono text-gray-700"> + {selectedNode.data.responseReceivedAt + ? new Date(selectedNode.data.responseReceivedAt).toLocaleString() + : '-'} + </div> + </div> + </div> + {selectedNode.data.querySentAt && selectedNode.data.responseReceivedAt && ( + <div className="mt-2 text-xs text-gray-500"> + Duration: {((selectedNode.data.responseReceivedAt - selectedNode.data.querySentAt) / 1000).toFixed(2)}s + </div> + )} + </div> + <div> <label className="block text-sm font-medium text-gray-700 mb-1">Active Context (Sent to LLM)</label> - <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto"> + <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto max-h-[200px]"> {JSON.stringify(getActiveContext(selectedNode.id), null, 2)} </pre> </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1">Node Traces (Incoming)</label> - <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto"> + <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto max-h-[200px]"> {JSON.stringify(selectedNode.data.traces, null, 2)} </pre> </div> @@ -665,27 +1685,401 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle }) => { </div> </div> )} + + {/* Merge Traces Modal */} + {showMergeModal && selectedNode && ( + <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowMergeModal(false)}> + <div + className={`rounded-xl shadow-2xl w-[500px] max-h-[80vh] flex flex-col ${ + isDark ? 'bg-gray-800' : 'bg-white' + }`} + onClick={(e) => e.stopPropagation()} + > + {/* Header */} + <div className={`flex items-center justify-between p-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <div className="flex items-center gap-2"> + <GitMerge size={20} className={isDark ? 'text-purple-400' : 'text-purple-600'} /> + <h3 className={`font-semibold text-lg ${isDark ? 'text-gray-100' : 'text-gray-900'}`}> + Create Merged Trace + </h3> + </div> + <button + onClick={() => setShowMergeModal(false)} + className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`} + > + <X size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + </button> + </div> + + {/* Trace Selection - draggable */} + <div className={`p-4 flex-1 overflow-y-auto ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> + <p className={`text-xs mb-3 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Select traces to merge. Drag to reorder for "Trace Order" strategy. + </p> + + <div className="space-y-1"> + {mergeOrder + .map(traceId => selectedNode.data.traces?.find((t: Trace) => t.id === traceId)) + .filter((trace): trace is Trace => trace !== undefined) + .map((trace) => { + const isSelected = mergeSelectedIds.includes(trace.id); + const isDragging = mergeDraggedId === trace.id; + + return ( + <div + key={trace.id} + draggable + onDragStart={(e) => handleMergeDragStart(e, trace.id)} + onDragOver={(e) => handleMergeDragOver(e, trace.id)} + onDragEnd={handleMergeDragEnd} + className={`flex items-center gap-3 p-2 rounded cursor-move transition-all ${ + isDragging ? 'opacity-50' : '' + } ${isSelected + ? 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' + }`} + > + <GripVertical size={16} className={isDark ? 'text-gray-600' : 'text-gray-300'} /> + + <input + type="checkbox" + checked={isSelected} + onChange={() => toggleMergeSelection(trace.id)} + /> + + <div className="w-3 h-3 rounded-full" style={{ backgroundColor: trace.color }}></div> + + <div className="flex-1"> + <span className={`font-mono text-sm ${isDark ? 'text-gray-300' : 'text-gray-600'}`}> + #{trace.id.slice(-6)} + </span> + <span className={`ml-2 text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + {trace.messages.length} msgs + </span> + </div> </div> ); -}; + })} + </div> + + {mergeSelectedIds.length < 2 && ( + <p className={`text-xs mt-3 text-center ${isDark ? 'text-orange-400' : 'text-orange-500'}`}> + Select at least 2 traces to merge + </p> + )} + </div> + + {/* Settings */} + <div className={`p-4 border-t ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> + <label className={`block text-xs font-bold mb-2 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + Merge Strategy + </label> + <select + value={mergeStrategy} + onChange={(e) => setMergeStrategy(e.target.value as MergeStrategy)} + className={`w-full border rounded-md p-2 text-sm mb-3 ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300' + }`} + > + <option value="query_time">By Query Time</option> + <option value="response_time">By Response Time</option> + <option value="trace_order">By Trace Order (drag to reorder)</option> + <option value="grouped">Grouped (each trace in full)</option> + <option value="interleaved">Interleaved (true timeline)</option> + <option value="summary">Summary (LLM compressed)</option> + </select> + + {/* Preview */} + {mergeSelectedIds.length >= 2 && ( + <> + <button + onClick={() => setShowMergePreview(!showMergePreview)} + className={`w-full text-xs py-1.5 rounded mb-2 ${ + isDark ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' : 'bg-gray-100 hover:bg-gray-200 text-gray-600' + }`} + > + {showMergePreview ? 'Hide Preview' : 'Show Preview'} ({getMergePreview().length} messages) + </button> + + {showMergePreview && ( + <div className={`max-h-[100px] overflow-y-auto mb-3 p-2 rounded text-xs ${ + isDark ? 'bg-gray-700' : 'bg-white border border-gray-200' + }`}> + {getMergePreview().map((msg, idx) => ( + <div key={idx} className={`mb-1 ${msg.role === 'user' ? 'text-blue-400' : isDark ? 'text-gray-300' : 'text-gray-600'}`}> + <span className="font-bold">{msg.role}:</span> {msg.content.slice(0, 40)}... + </div> + ))} + </div> + )} + </> + )} + + <button + onClick={handleCreateMergedTrace} + disabled={mergeSelectedIds.length < 2 || isSummarizingMerge} + className={`w-full py-2.5 rounded-md text-sm font-medium flex items-center justify-center gap-2 ${ + mergeSelectedIds.length >= 2 + ? isDark + ? 'bg-purple-600 hover:bg-purple-500 text-white disabled:bg-purple-900' + : 'bg-purple-600 hover:bg-purple-700 text-white disabled:bg-purple-300' + : isDark ? 'bg-gray-700 text-gray-500' : 'bg-gray-200 text-gray-400' + }`} + > + {isSummarizingMerge ? ( + <> + <Loader2 className="animate-spin" size={16} /> + Summarizing... + </> + ) : ( + <> + <GitMerge size={16} /> + Create Merged Trace + </> + )} + </button> + </div> + </div> + </div> + )} -// Helper component for icon -const Loader2 = ({ className, size }: { className?: string, size?: number }) => ( - <svg - xmlns="http://www.w3.org/2000/svg" - width={size || 24} - height={size || 24} - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - className={className} - > - <path d="M21 12a9 9 0 1 1-6.219-8.56" /> - </svg> -); + {/* Quick Chat Modal */} + {quickChatOpen && quickChatTrace && ( + <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => { onInteract?.(); closeQuickChat(); }}> + <div + className={`rounded-xl shadow-2xl w-[85vw] max-w-4xl h-[85vh] flex flex-col ${ + isDark ? 'bg-gray-800' : 'bg-white' + }`} + onClick={(e) => { e.stopPropagation(); onInteract?.(); }} + > + {/* Header */} + <div className={`flex items-center justify-between p-4 border-b ${ + isDark ? 'border-gray-700' : 'border-gray-200' + }`}> + <div className="flex items-center gap-3"> + <div className="w-3 h-3 rounded-full" style={{ backgroundColor: quickChatTrace.color }}></div> + <h3 className={`font-semibold text-lg ${isDark ? 'text-gray-100' : 'text-gray-900'}`}> + {quickChatNeedsDuplicate ? 'Duplicate & Quick Chat' : 'Quick Chat'} + </h3> + {quickChatNeedsDuplicate && ( + <span className={`text-xs px-2 py-0.5 rounded-full ${ + isDark ? 'bg-orange-900/50 text-orange-300' : 'bg-orange-100 text-orange-600' + }`}> + Will create new branch + </span> + )} + <span className={`text-xs font-mono ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>#{quickChatTrace.id.slice(-8)}</span> + </div> + <div className="flex items-center gap-3"> + {/* Model Selector */} + <select + value={quickChatModel} + onChange={(e) => setQuickChatModel(e.target.value)} + className={`border rounded-md px-3 py-1.5 text-sm ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 text-gray-900' + }`} + > + <optgroup label="Gemini"> + <option value="gemini-2.5-flash">gemini-2.5-flash</option> + <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option> + <option value="gemini-3-pro-preview">gemini-3-pro-preview</option> + </optgroup> + <optgroup label="OpenAI (Standard)"> + <option value="gpt-4.1">gpt-4.1</option> + <option value="gpt-4o">gpt-4o</option> + </optgroup> + <optgroup label="OpenAI (Reasoning)"> + <option value="gpt-5">gpt-5</option> + <option value="gpt-5-chat-latest">gpt-5-chat-latest</option> + <option value="gpt-5-mini">gpt-5-mini</option> + <option value="gpt-5-nano">gpt-5-nano</option> + <option value="gpt-5-pro">gpt-5-pro</option> + <option value="gpt-5.1">gpt-5.1</option> + <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option> + <option value="o3">o3</option> + </optgroup> + </select> + <button onClick={closeQuickChat} className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}> + <X size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} /> + </button> + </div> + </div> + + {/* Chat Messages */} + <div className={`flex-1 overflow-y-auto p-4 space-y-4 ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> + {quickChatMessages.length === 0 ? ( + <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + <MessageCircle size={48} className="mx-auto mb-2 opacity-50" /> + <p>Start a conversation with this trace's context</p> + </div> + ) : ( + quickChatMessages.map((msg, idx) => ( + <div + key={msg.id || idx} + className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} + > + <div className="flex items-start gap-2 max-w-[80%]"> + {/* Source trace indicator for merged traces */} + {msg.sourceTraceColor && msg.role !== 'user' && ( + <div + className="w-2 h-2 rounded-full mt-3 shrink-0" + style={{ backgroundColor: msg.sourceTraceColor }} + title={`From trace: ${msg.sourceTraceId?.slice(-6) || 'unknown'}`} + /> + )} + + <div + className={`rounded-lg px-4 py-2 ${ + msg.role === 'user' + ? 'bg-blue-600 text-white' + : isDark + ? 'bg-gray-800 border border-gray-700 text-gray-200 shadow-sm' + : 'bg-white border border-gray-200 shadow-sm' + }`} + style={msg.sourceTraceColor ? { borderLeftColor: msg.sourceTraceColor, borderLeftWidth: '3px' } : undefined} + > + {/* Source trace label for user messages from merged trace */} + {msg.sourceTraceColor && msg.role === 'user' && ( + <div + className="text-[10px] opacity-70 mb-1 flex items-center gap-1" + > + <div + className="w-2 h-2 rounded-full" + style={{ backgroundColor: msg.sourceTraceColor }} + /> + <span>from trace #{msg.sourceTraceId?.slice(-4)}</span> + </div> + )} + + {msg.role === 'user' ? ( + <p className="whitespace-pre-wrap">{msg.content}</p> + ) : ( + <div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}> + <ReactMarkdown>{msg.content}</ReactMarkdown> + </div> + )} + </div> + + {/* Source trace indicator for user messages (on the right side) */} + {msg.sourceTraceColor && msg.role === 'user' && ( + <div + className="w-2 h-2 rounded-full mt-3 shrink-0" + style={{ backgroundColor: msg.sourceTraceColor }} + title={`From trace: ${msg.sourceTraceId?.slice(-6) || 'unknown'}`} + /> + )} + </div> + </div> + )) + )} + {quickChatLoading && ( + <div className="flex justify-start"> + <div className={`rounded-lg px-4 py-3 shadow-sm ${ + isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white border border-gray-200' + }`}> + <Loader2 className="animate-spin text-blue-500" size={20} /> + </div> + </div> + )} + <div ref={quickChatEndRef} /> + </div> + + {/* Settings Row */} + <div className={`px-4 py-2 border-t flex items-center gap-4 text-xs ${ + isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-100 bg-white' + }`}> + {/* Temperature (hide for reasoning models) */} + {!['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3'].includes(quickChatModel) && ( + <div className="flex items-center gap-2"> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Temp:</span> + <input + type="range" + min="0" + max="2" + step="0.1" + value={quickChatTemp} + onChange={(e) => setQuickChatTemp(parseFloat(e.target.value))} + className="w-20" + /> + <span className={`w-8 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>{quickChatTemp}</span> + </div> + )} + + {/* Reasoning Effort (only for reasoning models, except chat-latest) */} + {['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'o3'].includes(quickChatModel) && ( + <div className="flex items-center gap-2"> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Effort:</span> + <select + value={quickChatEffort} + onChange={(e) => setQuickChatEffort(e.target.value as 'low' | 'medium' | 'high')} + className={`border rounded px-2 py-0.5 text-xs ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300' + }`} + > + <option value="low">Low</option> + <option value="medium">Medium</option> + <option value="high">High</option> + </select> + </div> + )} + + {/* Web Search */} + {(quickChatModel.startsWith('gemini') || quickChatModel.startsWith('gpt-5') || ['o3', 'gpt-4o'].includes(quickChatModel)) && ( + <label className="flex items-center gap-1 cursor-pointer"> + <input + type="checkbox" + checked={quickChatWebSearch} + onChange={(e) => setQuickChatWebSearch(e.target.checked)} + className="form-checkbox h-3 w-3" + /> + <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span> + </label> + )} + </div> + + {/* Input Area */} + <div className={`p-4 border-t ${isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'}`}> + <div className="flex gap-2"> + <textarea + ref={quickChatInputRef} + value={quickChatInput} + onChange={(e) => setQuickChatInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + // Only send if not loading + if (!quickChatLoading) { + handleQuickChatSend(); + } + } + }} + placeholder={quickChatLoading + ? "Waiting for response... (you can type here)" + : "Type your message... (Enter to send, Shift+Enter for new line)" + } + className={`flex-1 border rounded-lg px-4 py-3 text-sm resize-y min-h-[50px] max-h-[150px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ + isDark ? 'bg-gray-700 border-gray-600 text-gray-200 placeholder-gray-400' : 'border-gray-300' + }`} + autoFocus + /> + <button + onClick={handleQuickChatSend} + disabled={!quickChatInput.trim() || quickChatLoading} + className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed flex items-center gap-2" + > + {quickChatLoading ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />} + </button> + </div> + <p className={`text-[10px] mt-2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + Each message creates a new node on the canvas, automatically connected to this trace. + </p> + </div> + </div> + </div> + )} + </div> + ); +}; export default Sidebar; diff --git a/frontend/src/components/edges/MergedEdge.tsx b/frontend/src/components/edges/MergedEdge.tsx new file mode 100644 index 0000000..06c2bf8 --- /dev/null +++ b/frontend/src/components/edges/MergedEdge.tsx @@ -0,0 +1,77 @@ +import { BaseEdge, getBezierPath } from 'reactflow'; +import type { EdgeProps } from 'reactflow'; + +interface MergedEdgeData { + gradient?: string; + isMerged?: boolean; + colors?: string[]; +} + +const MergedEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + data, + markerEnd, +}: EdgeProps<MergedEdgeData>) => { + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + // If this is a merged trace, create a gradient + const isMerged = data?.isMerged; + const colors = data?.colors || []; + + if (isMerged && colors.length >= 2) { + const gradientId = `gradient-${id}`; + + return ( + <> + <defs> + <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%"> + {colors.map((color, idx) => ( + <stop + key={idx} + offset={`${(idx / (colors.length - 1)) * 100}%`} + stopColor={color} + /> + ))} + </linearGradient> + </defs> + <BaseEdge + id={id} + path={edgePath} + style={{ + ...style, + stroke: `url(#${gradientId})`, + strokeWidth: 3, + }} + markerEnd={markerEnd} + /> + </> + ); + } + + // Regular edge + return ( + <BaseEdge + id={id} + path={edgePath} + style={style} + markerEnd={markerEnd} + /> + ); +}; + +export default MergedEdge; + diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx index 592ab5b..dce1f2e 100644 --- a/frontend/src/components/nodes/LLMNode.tsx +++ b/frontend/src/components/nodes/LLMNode.tsx @@ -1,18 +1,19 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Handle, Position, type NodeProps, useUpdateNodeInternals, useEdges } from 'reactflow'; -import type { NodeData } from '../../store/flowStore'; +import type { NodeData, MergedTrace } from '../../store/flowStore'; import { Loader2, MessageSquare } from 'lucide-react'; import useFlowStore from '../../store/flowStore'; const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { - const { updateNodeData } = useFlowStore(); + const { theme, nodes } = useFlowStore(); + const [showPreview, setShowPreview] = useState(false); const updateNodeInternals = useUpdateNodeInternals(); const edges = useEdges(); // Force update handles when traces change useEffect(() => { updateNodeInternals(id); - }, [id, data.outgoingTraces, data.inputs, updateNodeInternals]); + }, [id, data.outgoingTraces, data.mergedTraces, data.inputs, updateNodeInternals]); // Determine how many input handles to show // We want to ensure there is always at least one empty handle at the bottom @@ -50,68 +51,260 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { const inputsToShow = Math.max(maxConnectedIndex + 2, 1); const isDisabled = data.disabled; + const isDark = theme === 'dark'; + + // Truncate preview content + const previewContent = data.response + ? data.response.slice(0, 200) + (data.response.length > 200 ? '...' : '') + : data.userPrompt + ? data.userPrompt.slice(0, 100) + (data.userPrompt.length > 100 ? '...' : '') + : null; return ( <div - className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all ${ + className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all relative ${ isDisabled - ? 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed' + ? isDark + ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed' + : 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed' : selected - ? 'bg-white border-blue-500' - : 'bg-white border-gray-200' + ? isDark + ? 'bg-gray-800 border-blue-400' + : 'bg-white border-blue-500' + : isDark + ? 'bg-gray-800 border-gray-600' + : 'bg-white border-gray-200' }`} style={{ pointerEvents: isDisabled ? 'none' : 'auto' }} + onMouseEnter={() => setShowPreview(true)} + onMouseLeave={() => setShowPreview(false)} > + {/* Content Preview Tooltip */} + {showPreview && previewContent && !isDisabled && ( + <div + className={`absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 w-64 p-3 rounded-lg shadow-xl text-xs whitespace-pre-wrap pointer-events-none ${ + isDark ? 'bg-gray-700 text-gray-200 border border-gray-600' : 'bg-white text-gray-700 border border-gray-200' + }`} + > + <div className={`font-semibold mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> + {data.response ? 'Response Preview' : 'Prompt Preview'} + </div> + {previewContent} + {/* Arrow */} + <div className={`absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent ${ + isDark ? 'border-t-gray-700' : 'border-t-white' + }`} /> + </div> + )} + <div className="flex items-center mb-2"> - <div className={`rounded-full w-8 h-8 flex justify-center items-center ${isDisabled ? 'bg-gray-200' : 'bg-gray-100'}`}> + <div className={`rounded-full w-8 h-8 flex justify-center items-center ${ + isDisabled + ? isDark ? 'bg-gray-700' : 'bg-gray-200' + : isDark ? 'bg-gray-700' : 'bg-gray-100' + }`}> {data.status === 'loading' ? ( <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> ) : ( - <MessageSquare className={`w-4 h-4 ${isDisabled ? 'text-gray-400' : 'text-gray-600'}`} /> + <MessageSquare className={`w-4 h-4 ${ + isDisabled + ? 'text-gray-500' + : isDark ? 'text-gray-400' : 'text-gray-600' + }`} /> )} </div> <div className="ml-2"> - <div className={`text-sm font-bold truncate max-w-[150px] ${isDisabled ? 'text-gray-400' : ''}`}> + <div className={`text-sm font-bold truncate max-w-[150px] ${ + isDisabled + ? 'text-gray-500' + : isDark ? 'text-gray-200' : 'text-gray-900' + }`}> {data.label} {isDisabled && <span className="text-xs ml-1">(disabled)</span>} </div> - <div className="text-xs text-gray-500">{data.model}</div> + <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>{data.model}</div> </div> </div> {/* Dynamic Inputs */} <div className="absolute left-0 top-0 bottom-0 flex flex-col justify-center w-4"> + {/* Regular input handles */} {Array.from({ length: inputsToShow }).map((_, i) => { // Find the connected edge to get color const connectedEdge = edges.find(e => e.target === id && e.targetHandle === `input-${i}`); const edgeColor = connectedEdge?.style?.stroke as string; + // Check if this is a merged trace connection + const edgeData = (connectedEdge as any)?.data; + const isMergedTrace = edgeData?.isMerged; + const mergedColors = edgeData?.colors as string[] | undefined; + + // Create gradient for merged traces + let handleBackground: string = edgeColor || '#3b82f6'; + if (isMergedTrace && mergedColors && mergedColors.length >= 2) { + const gradientStops = mergedColors.map((color, idx) => + `${color} ${(idx / mergedColors.length) * 100}%, ${color} ${((idx + 1) / mergedColors.length) * 100}%` + ).join(', '); + handleBackground = `linear-gradient(45deg, ${gradientStops})`; + } + return ( <div key={i} className="relative h-4 w-4 my-1"> <Handle type="target" position={Position.Left} id={`input-${i}`} - className="!w-3 !h-3 !left-[-6px]" + className="!w-3 !h-3 !left-[-6px] !border-0" style={{ top: '50%', transform: 'translateY(-50%)', - backgroundColor: edgeColor || '#3b82f6', // Default blue if not connected - border: edgeColor ? 'none' : undefined + background: handleBackground }} /> - <span className="absolute left-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none"> + <span className={`absolute left-4 top-[-2px] text-[9px] pointer-events-none ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> {i} </span> </div> ); })} + + {/* Prepend input handles for traces that this node is the HEAD of */} + {/* Show dashed handle if no prepend connection yet (can accept prepend) */} + {/* Show solid handle if already has prepend connection (connected) */} + {data.outgoingTraces && data.outgoingTraces + .filter(trace => { + // Check if this is a self trace, fork trace originated from this node + const isSelfTrace = trace.id === `trace-${id}`; + // Strict check for fork trace: must be originated from this node + const isForkTrace = trace.id.startsWith('fork-') && trace.sourceNodeId === id; + + if (!isSelfTrace && !isForkTrace) return false; + + // Check if this trace has any outgoing edges (downstream connections) + const hasDownstream = edges.some(e => + e.source === id && e.sourceHandle === `trace-${trace.id}` + ); + return hasDownstream; + }) + .map((trace) => { + // Check if there's already a prepend connection to this trace + const hasPrependConnection = edges.some(e => + e.target === id && e.targetHandle === `prepend-${trace.id}` + ); + const prependEdge = edges.find(e => + e.target === id && e.targetHandle === `prepend-${trace.id}` + ); + + return ( + <div key={`prepend-${trace.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to: ${trace.id}`}> + <Handle + type="target" + position={Position.Left} + id={`prepend-${trace.id}`} + className="!w-3 !h-3 !left-[-6px] !border-2" + style={{ + top: '50%', + transform: 'translateY(-50%)', + backgroundColor: hasPrependConnection + ? (prependEdge?.style?.stroke as string || trace.color) + : trace.color, + borderColor: isDark ? '#374151' : '#fff', + borderStyle: hasPrependConnection ? 'solid' : 'dashed' + }} + /> + </div> + ); + }) + } + + {/* Prepend input handles for merged traces - only show if merged trace has downstream */} + {data.mergedTraces && data.mergedTraces + .filter((merged: MergedTrace) => { + // Check if this merged trace has any outgoing edges + return edges.some(e => + e.source === id && e.sourceHandle === `trace-${merged.id}` + ); + }) + .map((merged: MergedTrace) => { + const connectedEdge = edges.find(e => e.target === id && e.targetHandle === `prepend-${merged.id}`); + const edgeColor = connectedEdge?.style?.stroke as string; + + // Create gradient for merged trace handle + const colors = merged.colors.length > 0 ? merged.colors : ['#888']; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + const stripeGradient = `linear-gradient(45deg, ${gradientStops})`; + + return ( + <div key={`prepend-${merged.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to merged: ${merged.id}`}> + <Handle + type="target" + position={Position.Left} + id={`prepend-${merged.id}`} + className="!w-3 !h-3 !left-[-6px] !border-2" + style={{ + top: '50%', + transform: 'translateY(-50%)', + background: edgeColor || stripeGradient, + borderColor: isDark ? '#374151' : '#fff', + borderStyle: 'dashed' + }} + /> + </div> + ); + })} </div> {/* Dynamic Outputs (Traces) */} <div className="absolute right-0 top-0 bottom-0 flex flex-col justify-center w-4"> - {/* 1. Outgoing Traces (Pass-through + Self) */} - {data.outgoingTraces && data.outgoingTraces.map((trace, i) => ( + {/* 0. Incoming Trace Continue Handles - allow continuing an incoming trace */} + {/* Only show if there's NO downstream edge yet (dashed = waiting for connection) */} + {data.traces && data.traces + .filter((trace: Trace) => { + // Only show continue handle if NOT already connected downstream + const evolvedTraceId = `${trace.id}_${id}`; + const hasOutgoingEdge = edges.some(e => + e.source === id && + (e.sourceHandle === `trace-${trace.id}` || e.sourceHandle === `trace-${evolvedTraceId}`) + ); + // Only show dashed handle if not yet connected + return !hasOutgoingEdge; + }) + .map((trace: Trace) => { + const evolvedTraceId = `${trace.id}_${id}`; + return ( + <div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}> + <Handle + type="source" + position={Position.Right} + id={`trace-${evolvedTraceId}`} + className="!w-3 !h-3 !right-[-6px]" + style={{ + backgroundColor: trace.color, + top: '50%', + transform: 'translateY(-50%)', + border: `2px dashed ${isDark ? '#374151' : '#fff'}` + }} + /> + </div> + ); + })} + + {/* 1. Regular Outgoing Traces (Self + Forks + Connected Pass-through) */} + {/* Only show traces that have actual downstream edges */} + {data.outgoingTraces && data.outgoingTraces + .filter(trace => { + // Exclude merged traces - they have their own section + if (trace.id.startsWith('merged-')) return false; + + // Only show if there's an actual downstream edge using this trace + const hasDownstream = edges.some(e => + e.source === id && e.sourceHandle === `trace-${trace.id}` + ); + return hasDownstream; + }) + .map((trace) => ( <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}> <Handle type="source" @@ -127,7 +320,33 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { </div> ))} - {/* 2. New Branch Generator Handle (Always visible) */} + {/* 2. Merged Trace Handles (with alternating color stripes) */} + {data.mergedTraces && data.mergedTraces.map((merged: MergedTrace) => { + // Create a gradient background from the source trace colors + const colors = merged.colors.length > 0 ? merged.colors : ['#888']; + const gradientStops = colors.map((color, idx) => + `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%` + ).join(', '); + const stripeGradient = `linear-gradient(45deg, ${gradientStops})`; + + return ( + <div key={merged.id} className="relative h-4 w-4 my-1" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}> + <Handle + type="source" + position={Position.Right} + id={`trace-${merged.id}`} + className="!w-3 !h-3 !right-[-6px] !border-0" + style={{ + background: stripeGradient, + top: '50%', + transform: 'translateY(-50%)' + }} + /> + </div> + ); + })} + + {/* 3. New Branch Generator Handle (Always visible) */} <div className="relative h-4 w-4 my-1" title="Create New Branch"> <Handle type="source" @@ -136,7 +355,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => { className="!w-3 !h-3 !bg-gray-400 !right-[-6px]" style={{ top: '50%', transform: 'translateY(-50%)' }} /> - <span className="absolute right-4 top-[-2px] text-[9px] text-gray-400 pointer-events-none w-max"> + <span className={`absolute right-4 top-[-2px] text-[9px] pointer-events-none w-max ${isDark ? 'text-gray-500' : 'text-gray-400'}`}> + New </span> </div> |
